From dbe4b36c62896a7307251491dc9ca29b0eabd696 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 26 Jul 2026 01:26:12 +0200 Subject: [PATCH] feat(write-skill): chronology guard + explicit production opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two guards, both from real incidents in the same session. 1. invoice-create.sh — chronology (CGI art. 289). Dolibarr assigns the number at validation, in creation order, so issuing a document dated BEFORE the last one already issued gives a higher number to an earlier date. The July plan walked straight into it: the M3 deferred part is due 2026-10-23 and must be issued at D-60 (24/08) to stay under the L.441-10 I ceiling, while the M4 fixed part is dated 23/08 — issue them in the wrong order and the numbering breaks. The guard reads the last issued document of the same kind and refuses an earlier date, with ARCO_ALLOW_BACKDATE as a loud, documented override. Verified: refuses a 01/07 invoice against FAC008 (23/07), accepts 23/08. 2. test/scripts/guard.ts — production opt-in. The sandbox-only guard had no way to express a deliberate production run, so any prod work meant bypassing it entirely (which is how guards die). Production now requires BOTH ARCO_ALLOW_PRODUCTION= and ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD, and prints a banner. Nothing reaches prod by inheriting an ambient variable. Also fixes a misleading "(sandbox verified)" log that printed even on prod. grantAgentRight.ts joins the repo (it was never committed) and gains --revoke, so a temporarily elevated right can be handed back — used today to attach a payment in production and revoked immediately after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh --- .../scripts/invoice-create.sh | 31 +++++++ test/grantAgentRight.ts | 89 +++++++++++++++++++ test/sandboxCurrencySetup.ts | 2 +- test/sandboxLegalSetup.ts | 2 +- test/scripts/guard.ts | 17 +++- 5 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 test/grantAgentRight.ts diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh b/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh index 18b4a5d..9087bec 100755 --- a/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh +++ b/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh @@ -188,6 +188,37 @@ if [[ -n "${MATCH}" ]]; then fi # --- 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}")" if [[ ! "${ID}" =~ ^[0-9]+$ ]]; then echo "invoice-create.sh: create did not return an id: ${ID}" >&2 diff --git a/test/grantAgentRight.ts b/test/grantAgentRight.ts new file mode 100644 index 0000000..853fcb6 --- /dev/null +++ b/test/grantAgentRight.ts @@ -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 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(); +} diff --git a/test/sandboxCurrencySetup.ts b/test/sandboxCurrencySetup.ts index 1346f84..f7f3278 100644 --- a/test/sandboxCurrencySetup.ts +++ b/test/sandboxCurrencySetup.ts @@ -27,7 +27,7 @@ const rate = pick("--rate", "1.14416"); const shotDir = Deno.env.get("SHOT_DIR") ?? "/tmp"; const dolibarrAddress = assertSandbox(); -console.log(`target: ${dolibarrAddress} (sandbox verified)`); +console.log(`target: ${dolibarrAddress} (guard passed)`); const adminCredentials = { username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", diff --git a/test/sandboxLegalSetup.ts b/test/sandboxLegalSetup.ts index 9795360..b7d1d4b 100644 --- a/test/sandboxLegalSetup.ts +++ b/test/sandboxLegalSetup.ts @@ -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. const dolibarrAddress = assertSandbox(); -console.log(`target: ${dolibarrAddress} (sandbox verified)`); +console.log(`target: ${dolibarrAddress} (guard passed)`); const adminCredentials = { username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined", diff --git a/test/scripts/guard.ts b/test/scripts/guard.ts index 615a8bb..dd7262c 100644 --- a/test/scripts/guard.ts +++ b/test/scripts/guard.ts @@ -37,12 +37,27 @@ export function assertSandbox(address?: string): string { } 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( `REFUSED: '${host}' is not the sandbox.\n` + "UI admin scripts may only drive erp-sandbox.*; production is changed " + "by the operator through the human-gated path, never by a script.\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; -- 2.54.0