feat(write-skill): chronology guard + explicit production opt-in #78
@@ -188,6 +188,37 @@ if [[ -n "${MATCH}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Create (no match) ----------------------------------------------------------
|
# --- Create (no match) ----------------------------------------------------------
|
||||||
|
# --- Chronology guard (CGI art. 289: numbering must be chronological) --------
|
||||||
|
# Dolibarr assigns the next number at validation, in creation order — so issuing
|
||||||
|
# a document dated BEFORE the last one already issued yields a higher number on
|
||||||
|
# an earlier date, which is a numbering break. This bites whenever two documents
|
||||||
|
# of the same cycle are issued on different days (a deferred part issued after
|
||||||
|
# the next fixed part, for instance). Refuse rather than create the break.
|
||||||
|
NEW_DATE="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['date'])" "${BODY}")"
|
||||||
|
LAST="$("${W}" GET "${ENDPOINT}?sortfield=t.rowid&sortorder=DESC&limit=1" 2>/dev/null \
|
||||||
|
| python3 -c "
|
||||||
|
import json,sys
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
if isinstance(d,list) and d: print(f\"{d[0].get('date','0')}|{d[0].get('ref','?')}\")
|
||||||
|
else: print('0|-')
|
||||||
|
except Exception: print('0|-')" 2>/dev/null || echo "0|-")"
|
||||||
|
LAST_DATE="${LAST%%|*}"; LAST_REF="${LAST##*|}"
|
||||||
|
if [[ "${LAST_DATE}" =~ ^[0-9]+$ ]] && (( LAST_DATE > 0 )) && (( NEW_DATE < LAST_DATE )); then
|
||||||
|
if [[ "${ARCO_ALLOW_BACKDATE:-}" != "I-UNDERSTAND-THIS-BREAKS-CHRONOLOGY" ]]; then
|
||||||
|
printf 'invoice-create.sh: REFUSED — chronology break.\n' >&2
|
||||||
|
printf ' new document dated %s, but %s is already issued at %s.\n' \
|
||||||
|
"$(date -r "${NEW_DATE}" +%d/%m/%Y 2>/dev/null || echo "${NEW_DATE}")" \
|
||||||
|
"${LAST_REF}" "$(date -r "${LAST_DATE}" +%d/%m/%Y 2>/dev/null || echo "${LAST_DATE}")" >&2
|
||||||
|
printf ' Numbering follows creation order, so this would give a higher number to an\n' >&2
|
||||||
|
printf ' earlier date (CGI art. 289). Issue in chronological order, or set\n' >&2
|
||||||
|
printf ' ARCO_ALLOW_BACKDATE=I-UNDERSTAND-THIS-BREAKS-CHRONOLOGY to override.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'invoice-create.sh: WARNING — backdating past %s (%s), override accepted.\n' \
|
||||||
|
"${LAST_REF}" "$(date -r "${LAST_DATE}" +%d/%m/%Y 2>/dev/null)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
ID="$("${W}" POST "${ENDPOINT}" "${BODY}")"
|
ID="$("${W}" POST "${ENDPOINT}" "${BODY}")"
|
||||||
if [[ ! "${ID}" =~ ^[0-9]+$ ]]; then
|
if [[ ! "${ID}" =~ ^[0-9]+$ ]]; then
|
||||||
echo "invoice-create.sh: create did not return an id: ${ID}" >&2
|
echo "invoice-create.sh: create did not return an id: ${ID}" >&2
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
Grant a Dolibarr permission to the sandbox write agent, through the admin UI.
|
||||||
|
|
||||||
|
The REST API cannot do this (the write agent is non-admin by design), and the
|
||||||
|
permission set it ships with is deliberately narrow: invoices, thirdparties,
|
||||||
|
payments. Anything beyond that is an explicit, auditable grant — which is what
|
||||||
|
this script performs, one right at a time, on the sandbox only.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab \
|
||||||
|
deno run -A test/grantAgentRight.ts --user-id 4 --match "proposition"
|
||||||
|
*/
|
||||||
|
import "load_dotenv";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import login from "./scripts/login.ts";
|
||||||
|
import { assertSandbox } from "./scripts/guard.ts";
|
||||||
|
|
||||||
|
const argv = Deno.args;
|
||||||
|
const pick = (f: string, d: string) =>
|
||||||
|
argv.includes(f) ? argv[argv.indexOf(f) + 1] : d;
|
||||||
|
const userId = pick("--user-id", "4");
|
||||||
|
const match = pick("--match", "");
|
||||||
|
const dryRun = argv.includes("--dry-run");
|
||||||
|
const revoke = argv.includes("--revoke");
|
||||||
|
const shotDir = Deno.env.get("SHOT_DIR") ?? "/tmp";
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
console.error("--match <substring of the permission label> is required");
|
||||||
|
Deno.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dolibarrAddress = assertSandbox();
|
||||||
|
console.log(`target: ${dolibarrAddress} (guard passed)`);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ locale: "fr-FR" });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login.doAdminLogin({
|
||||||
|
page,
|
||||||
|
dolibarrAddress,
|
||||||
|
adminCredentials: {
|
||||||
|
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
|
||||||
|
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${dolibarrAddress}/user/perms.php?id=${userId}`);
|
||||||
|
const who = await page.locator("h1, .titre, .nowrap").first()
|
||||||
|
.textContent({ timeout: 5000 }).catch(() => "(title not found)");
|
||||||
|
console.log(`permissions page for: ${who?.trim()}`);
|
||||||
|
console.log(`url: ${page.url()}`);
|
||||||
|
|
||||||
|
// Rows whose label matches, that are not already granted (a granted row shows
|
||||||
|
// the "remove" link instead of the "add" one).
|
||||||
|
const rows = page.locator("tr", {
|
||||||
|
has: page.locator("td", { hasText: new RegExp(match, "i") }),
|
||||||
|
});
|
||||||
|
const total = await rows.count();
|
||||||
|
console.log(`rows matching /${match}/i: ${total}`);
|
||||||
|
|
||||||
|
let granted = 0;
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const row = rows.nth(i);
|
||||||
|
const label = (await row.textContent())?.replace(/\s+/g, " ").trim().slice(0, 90);
|
||||||
|
const action = revoke ? "delrights" : "addrights";
|
||||||
|
const addLink = row.locator(`a[href*="action=${action}"]`);
|
||||||
|
if (await addLink.count() === 0) {
|
||||||
|
console.log(` [skip, already ${revoke ? "revoked" : "granted"} or not actionable] ${label}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(` [dry-run would ${revoke ? "revoke" : "grant"}] ${label}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await addLink.first().click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
granted++;
|
||||||
|
console.log(` [${revoke ? "revoked" : "granted"}] ${label}`);
|
||||||
|
// The DOM is rebuilt after each grant — re-resolve on the next iteration.
|
||||||
|
await page.goto(`${dolibarrAddress}/user/perms.php?id=${userId}`);
|
||||||
|
}
|
||||||
|
console.log(`${revoke ? "revoked" : "granted"} ${granted} right(s)`);
|
||||||
|
await page.screenshot({ path: `${shotDir}/perms.png`, fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ const rate = pick("--rate", "1.14416");
|
|||||||
const shotDir = Deno.env.get("SHOT_DIR") ?? "/tmp";
|
const shotDir = Deno.env.get("SHOT_DIR") ?? "/tmp";
|
||||||
|
|
||||||
const dolibarrAddress = assertSandbox();
|
const dolibarrAddress = assertSandbox();
|
||||||
console.log(`target: ${dolibarrAddress} (sandbox verified)`);
|
console.log(`target: ${dolibarrAddress} (guard passed)`);
|
||||||
|
|
||||||
const adminCredentials = {
|
const adminCredentials = {
|
||||||
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
|
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const shotDir = Deno.env.get("SHOT_DIR") ?? "/tmp";
|
|||||||
|
|
||||||
// The guard reads the ambient env — an unqualified run dies here, by design.
|
// The guard reads the ambient env — an unqualified run dies here, by design.
|
||||||
const dolibarrAddress = assertSandbox();
|
const dolibarrAddress = assertSandbox();
|
||||||
console.log(`target: ${dolibarrAddress} (sandbox verified)`);
|
console.log(`target: ${dolibarrAddress} (guard passed)`);
|
||||||
|
|
||||||
const adminCredentials = {
|
const adminCredentials = {
|
||||||
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
|
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
|
||||||
|
|||||||
+16
-1
@@ -37,12 +37,27 @@ export function assertSandbox(address?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_HOST_PATTERN.test(host)) {
|
if (!ALLOWED_HOST_PATTERN.test(host)) {
|
||||||
|
// Production opt-in: deliberate, loud, and per-run. The operator must name
|
||||||
|
// the exact host AND type the confirmation phrase, so nothing reaches prod
|
||||||
|
// by inheriting an ambient variable — the same posture the promote flow
|
||||||
|
// takes with DOLIBARR_PROD_WRITE_KEY / ARCO_PROMOTE_CONFIRM.
|
||||||
|
const allowProd = Deno.env.get("ARCO_ALLOW_PRODUCTION") ?? "";
|
||||||
|
const confirm = Deno.env.get("ARCO_PROD_CONFIRM") ?? "";
|
||||||
|
if (allowProd === host && confirm === "I-UNDERSTAND-THIS-WRITES-PROD") {
|
||||||
|
console.warn(
|
||||||
|
`\n*** PRODUCTION TARGET: ${host} — explicit opt-in accepted. ***\n` +
|
||||||
|
" Every write below hits the real ledger and cannot be undone.\n",
|
||||||
|
);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
throw new UnsafeTargetError(
|
throw new UnsafeTargetError(
|
||||||
`REFUSED: '${host}' is not the sandbox.\n` +
|
`REFUSED: '${host}' is not the sandbox.\n` +
|
||||||
"UI admin scripts may only drive erp-sandbox.*; production is changed " +
|
"UI admin scripts may only drive erp-sandbox.*; production is changed " +
|
||||||
"by the operator through the human-gated path, never by a script.\n" +
|
"by the operator through the human-gated path, never by a script.\n" +
|
||||||
"Override the ambient env explicitly, e.g.\n" +
|
"Override the ambient env explicitly, e.g.\n" +
|
||||||
" DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab deno run ...",
|
" DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab deno run ...\n" +
|
||||||
|
"To target production on purpose, set BOTH:\n" +
|
||||||
|
` ARCO_ALLOW_PRODUCTION=${host} ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return target;
|
return target;
|
||||||
|
|||||||
Reference in New Issue
Block a user