@intutic/sync-daemon 2.9.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/disconnect/harnesses.d.ts.map +1 -1
- package/dist/disconnect/harnesses.js +3 -2
- package/dist/disconnect/harnesses.js.map +1 -1
- package/dist/harness/antigravityCliHooks.js +3 -3
- package/dist/harness/antigravityCliHooks.js.map +1 -1
- package/dist/harness/antigravityHooks.d.ts +8 -0
- package/dist/harness/antigravityHooks.d.ts.map +1 -1
- package/dist/harness/antigravityHooks.js +27 -5
- package/dist/harness/antigravityHooks.js.map +1 -1
- package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
- package/dist/harness/claudeCodeHooks.js +58 -16
- package/dist/harness/claudeCodeHooks.js.map +1 -1
- package/dist/harness/clineHooks.d.ts.map +1 -1
- package/dist/harness/clineHooks.js +4 -2
- package/dist/harness/clineHooks.js.map +1 -1
- package/dist/harness/codexHooks.d.ts.map +1 -1
- package/dist/harness/codexHooks.js +4 -2
- package/dist/harness/codexHooks.js.map +1 -1
- package/dist/harness/cursorHooks.d.ts.map +1 -1
- package/dist/harness/cursorHooks.js +8 -3
- package/dist/harness/cursorHooks.js.map +1 -1
- package/dist/harness/gateBody.d.ts +177 -4
- package/dist/harness/gateBody.d.ts.map +1 -1
- package/dist/harness/gateBody.js +1658 -26
- package/dist/harness/gateBody.js.map +1 -1
- package/dist/harness/githubCopilotHooks.js +3 -3
- package/dist/harness/githubCopilotHooks.js.map +1 -1
- package/dist/harness/gooseHooks.d.ts.map +1 -1
- package/dist/harness/gooseHooks.js +8 -1
- package/dist/harness/gooseHooks.js.map +1 -1
- package/dist/harness/grokHooks.js +2 -2
- package/dist/harness/hermesHooks.js +1 -1
- package/dist/harness/hermesHooks.js.map +1 -1
- package/dist/harness/museHooks.d.ts.map +1 -1
- package/dist/harness/museHooks.js +4 -2
- package/dist/harness/museHooks.js.map +1 -1
- package/dist/harness/n8nHooks.js +1 -1
- package/dist/harness/openCodeHooks.d.ts.map +1 -1
- package/dist/harness/openCodeHooks.js +32 -8
- package/dist/harness/openCodeHooks.js.map +1 -1
- package/dist/harness/openclawHooks.d.ts.map +1 -1
- package/dist/harness/openclawHooks.js +19 -8
- package/dist/harness/openclawHooks.js.map +1 -1
- package/dist/harness/piHooks.js +7 -7
- package/dist/harness/windsurfHooks.js +2 -2
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/approvedBypasses.d.ts +17 -4
- package/dist/lib/approvedBypasses.d.ts.map +1 -1
- package/dist/lib/approvedBypasses.js +17 -4
- package/dist/lib/approvedBypasses.js.map +1 -1
- package/dist/lib/approvedPlans.d.ts +73 -0
- package/dist/lib/approvedPlans.d.ts.map +1 -0
- package/dist/lib/approvedPlans.js +124 -0
- package/dist/lib/approvedPlans.js.map +1 -0
- package/dist/lib/decideLogs.d.ts +46 -0
- package/dist/lib/decideLogs.d.ts.map +1 -0
- package/dist/lib/decideLogs.js +160 -0
- package/dist/lib/decideLogs.js.map +1 -0
- package/dist/lib/policySnapshot.d.ts +143 -3
- package/dist/lib/policySnapshot.d.ts.map +1 -1
- package/dist/lib/policySnapshot.js +480 -14
- package/dist/lib/policySnapshot.js.map +1 -1
- package/dist/lib/refusalDetailPy.d.ts +11 -0
- package/dist/lib/refusalDetailPy.d.ts.map +1 -0
- package/dist/lib/refusalDetailPy.js +109 -0
- package/dist/lib/refusalDetailPy.js.map +1 -0
- package/dist/lib/ruleRecordPy.d.ts +11 -0
- package/dist/lib/ruleRecordPy.d.ts.map +1 -0
- package/dist/lib/ruleRecordPy.js +277 -0
- package/dist/lib/ruleRecordPy.js.map +1 -0
- package/dist/lib/scopeMatchPy.d.ts +11 -0
- package/dist/lib/scopeMatchPy.d.ts.map +1 -0
- package/dist/lib/scopeMatchPy.js +278 -0
- package/dist/lib/scopeMatchPy.js.map +1 -0
- package/dist/lib/testOutcomePy.d.ts +11 -0
- package/dist/lib/testOutcomePy.d.ts.map +1 -0
- package/dist/lib/testOutcomePy.js +371 -0
- package/dist/lib/testOutcomePy.js.map +1 -0
- package/dist/skillWriter.d.ts +1 -1
- package/dist/skillWriter.d.ts.map +1 -1
- package/dist/skillWriter.js +7 -0
- package/dist/skillWriter.js.map +1 -1
- package/dist/syncCycle.d.ts +15 -2
- package/dist/syncCycle.d.ts.map +1 -1
- package/dist/syncCycle.js +15 -3
- package/dist/syncCycle.js.map +1 -1
- package/package.json +5 -5
package/dist/harness/gateBody.js
CHANGED
|
@@ -50,11 +50,16 @@
|
|
|
50
50
|
*/
|
|
51
51
|
import { HOLD_APPROVAL_HINT_TEMPLATE, HOLD_ID_PLACEHOLDER, INTERVENTION_MODE_RECORD_TAG, OPAQUE_REFUSAL_MESSAGE, } from '@intutic/shared-types';
|
|
52
52
|
import { DESTRUCTIVE_COMMAND_PATTERNS, NORMALISE_CONTRACT, ruleFlags, staticFloorPatterns, } from './protectedPaths.js';
|
|
53
|
-
import { ARGUMENTS_SIZE_LIMIT, CHAOS_GATE_JS_SOURCE, COMMAND_SIZE_LIMIT, gateDeadlineMs, MCP_ALLOWLIST_JS_SOURCE, MCP_ALLOWLIST_RECORD_TAG, MCP_REGISTRY_JS_SOURCE, MCP_REGISTRY_RECORD_TAG, MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE, PHRASES_JS_SOURCE, SEQUENCE_JS_SOURCE, } from '@intutic/shared-types';
|
|
53
|
+
import { ARGUMENTS_SIZE_LIMIT, CHAOS_GATE_JS_SOURCE, COMMAND_SIZE_LIMIT, DECIDE_BUDGET_MARGIN_MS, DECIDE_FALLBACK_PREFIX, DECIDE_GATE_JS_SOURCE, DECIDE_MIN_CALL_MS, DECIDE_RECORD_TAG, decideBudgetMs, decideLogRedactionSources, GATE_DEADLINE_MARGIN_MS, HOOK_TIMEOUT_SECONDS, SNAPSHOT_FORMAT, SNAPSHOT_FORMAT_RECORD_TAG, gateDeadlineMs, MCP_ALLOWLIST_JS_SOURCE, MCP_ALLOWLIST_RECORD_TAG, MCP_REGISTRY_JS_SOURCE, MCP_REGISTRY_RECORD_TAG, MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE, PHRASES_JS_SOURCE, SEQUENCE_JS_SOURCE, } from '@intutic/shared-types';
|
|
54
54
|
import { CHAOS_PY_SOURCE } from '../lib/chaosPy.js';
|
|
55
55
|
import { MCP_REGISTRY_PY_SOURCE } from '../lib/mcpRegistryPy.js';
|
|
56
56
|
import { PHRASES_PY_SOURCE } from '../lib/phrasesPy.js';
|
|
57
57
|
import { SEQUENCE_PY_SOURCE } from '../lib/sequencePy.js';
|
|
58
|
+
import { REFUSAL_DETAIL_JS_SOURCE, RULE_RECORD_JS_SOURCE, RULE_RECORD_TAG, RULE_SNAPSHOT_FORMAT, SCOPE_MATCH_JS_SOURCE, TEST_OUTCOME_JS_SOURCE, LANE_PHRASES, } from '@intutic/shared-types';
|
|
59
|
+
import { TEST_OUTCOME_PY_SOURCE } from '../lib/testOutcomePy.js';
|
|
60
|
+
import { SCOPE_MATCH_PY_SOURCE } from '../lib/scopeMatchPy.js';
|
|
61
|
+
import { RULE_RECORD_PY_SOURCE } from '../lib/ruleRecordPy.js';
|
|
62
|
+
import { REFUSAL_DETAIL_PY_SOURCE } from '../lib/refusalDetailPy.js';
|
|
58
63
|
/**
|
|
59
64
|
* Bumped when the emitted evaluator changes shape.
|
|
60
65
|
*
|
|
@@ -251,8 +256,132 @@ import { SEQUENCE_PY_SOURCE } from '../lib/sequencePy.js';
|
|
|
251
256
|
* audit line keeps the full reason. A TRANSPARENT or SILENT_LOG snapshot has no
|
|
252
257
|
* such line and is unchanged. A v20 gate skips the line, as it skips any
|
|
253
258
|
* two-column one, and names the rule.
|
|
259
|
+
*
|
|
260
|
+
* v22: decide rules in the snapshot (snapshot format 2). A snapshot carrying
|
|
261
|
+
* one starts with an `@format` data line (tag and `2`) and holds an
|
|
262
|
+
* `@decide` record per tool-call rule (five columns: tag, fallback line id or
|
|
263
|
+
* `-`, stamped rollout, comma-joined tools, base64 of the rule's record;
|
|
264
|
+
* `decideRecord.ts` in `@intutic/shared-types`), inside the digest. Each rule
|
|
265
|
+
* stamped `enforce` also has a fallback line, an ordinary `block` rule on its
|
|
266
|
+
* tools with an id starting `decide-fallback:`, placed right after the
|
|
267
|
+
* SSO-group rules. The JS gates decode each record, rule and hash checked
|
|
268
|
+
* (`decide.ts`, emitted as source), and carry the ones that decode as
|
|
269
|
+
* `decideRules`; the bash gates keep the records whole for the CLI. Every
|
|
270
|
+
* family evaluates decide rules ({@link GATE_EVALUATES_DECIDE}), so it skips
|
|
271
|
+
* a fallback line, but only when a decoded record names it (the bash gates:
|
|
272
|
+
* an `@decide` record of five columns); a record that does not decode leaves
|
|
273
|
+
* its fallback line in force. The decide tier runs after every deterministic
|
|
274
|
+
* tier allowed the call, and only then (a deterministic refusal asks the
|
|
275
|
+
* engine nothing): the JS gates ask the self-hosted engine in process
|
|
276
|
+
* (`intuticGateWithDecide`, an async wrapper around the synchronous tiers),
|
|
277
|
+
* the bash gates and the n8n gate through `intutic decide check --stdin`,
|
|
278
|
+
* each within its harness's budget (`decideBudgetMs` in gateLimits.ts). A
|
|
279
|
+
* rule refuses with `DECIDE_REFUSED`; one that cannot decide asks a person
|
|
280
|
+
* through the harness's {@link AskContract}, or refuses with
|
|
281
|
+
* `DECIDE_ESCALATED` where there is none; an engine (or CLI) that cannot
|
|
282
|
+
* answer refuses an enforcing rule with `GOVERNANCE_UNAVAILABLE` and lets a
|
|
283
|
+
* shadow rule's call on. Each evaluated call is written to the decide log
|
|
284
|
+
* (`~/.intutic/logs/decide-<date>.jsonl`, `decideLog.ts`): the JS gates write
|
|
285
|
+
* it in process, with the record, redaction and drift stats the CLI writes
|
|
286
|
+
* for the bash and n8n gates; a write that fails changes nothing. The
|
|
287
|
+
* synchronous `intuticGate`, which cannot ask
|
|
288
|
+
* the engine, refuses any call an enforcing decide rule names. An unverified
|
|
289
|
+
* snapshot drops the records with the rest of the dynamic tier. A gate
|
|
290
|
+
* reading a format above 2 reports `snapshot_format_newer` and enforces what
|
|
291
|
+
* it reads. A v21 gate skips the records, as it skips any line under six
|
|
292
|
+
* columns, and refuses each fallback line's tools.
|
|
293
|
+
*
|
|
294
|
+
* v23: rule records, post-tool outcomes and the tier order. A
|
|
295
|
+
* snapshot carrying any `@rule` record (five columns: tag, fallback line id
|
|
296
|
+
* or `-`, kind, stamped rollout, base64 of the canonical payload;
|
|
297
|
+
* `gateRuleRecord.ts` in `@intutic/shared-types`) starts with `@format 3`. Three kinds: `requires_outcome`
|
|
298
|
+
* (`requires_before: action:run_tests[ok] -> X`), `scope_paths` (under
|
|
299
|
+
* `scope_paths_mode: enforce`) and `plan_approval` (`required | strict`). Each
|
|
300
|
+
* enforcing record has a `<kind>-fallback:` line, an ordinary `block` rule
|
|
301
|
+
* placed after the SSO-group rules and the decide fallbacks, which a gate
|
|
302
|
+
* skips only when a record that decodes names it and its family evaluates
|
|
303
|
+
* that kind ({@link GATE_EVALUATES_RULE_RECORDS}); the JS gates decode
|
|
304
|
+
* in process, the bash gates in the extractor's Python. A post-tool
|
|
305
|
+
* invocation (`--event post`, or the writer's own post handler) records a
|
|
306
|
+
* test run's outcome in `~/.intutic/hooks/state/outcomes/<sha256(harness NUL
|
|
307
|
+
* session)[:32]>.json` and always allows: the fail-closed prelude exits 0
|
|
308
|
+
* in post mode. Only the harnesses whose post-tool payload was captured
|
|
309
|
+
* report outcomes ({@link POST_TOOL_OBSERVABLE}); on the others a qualified
|
|
310
|
+
* rule refuses with the "not observable" reason. An `[ok]` outcome goes
|
|
311
|
+
* stale on any later file change in the session. Refusals carry
|
|
312
|
+
* `PREREQUISITE_UNMET`, `OUT_OF_SCOPE`, `PLAN_NOT_APPROVED` and `OFF_PLAN`.
|
|
313
|
+
* The tiers run in one order in every family: deterministic refusals (the
|
|
314
|
+
* floor, the snapshot's block rules, `requires_outcome`, the MCP registry
|
|
315
|
+
* and allowlist), then holds, then trajectory (`scope_paths`,
|
|
316
|
+
* `plan_approval`), then decide; a decide allow never releases a hold. The
|
|
317
|
+
* classifier knows `action:run_tests`, `action:http_post`, `action:migrate`,
|
|
318
|
+
* `action:prod_write`, `action:destroy`, `action:payment` and the
|
|
319
|
+
* `action:force_push` compound. A v22 gate skips the records, refuses each
|
|
320
|
+
* fallback line's calls, and holds lane and revert holds through their
|
|
321
|
+
* `phrase` rules.
|
|
322
|
+
*/
|
|
323
|
+
export const GATE_VERSION = 23;
|
|
324
|
+
/**
|
|
325
|
+
* Whether each family of rendered gate evaluates each `@rule` kind. A family
|
|
326
|
+
* that does not keeps the kind's fallback lines in force, so it refuses what
|
|
327
|
+
* the rule covers. The n8n workflow gate has no session, no tool results and
|
|
328
|
+
* no file targets, so it keeps every fallback line.
|
|
329
|
+
*/
|
|
330
|
+
export const GATE_EVALUATES_RULE_RECORDS = {
|
|
331
|
+
js: { requires_outcome: true, scope_paths: true, plan_approval: true },
|
|
332
|
+
bash: { requires_outcome: true, scope_paths: true, plan_approval: true },
|
|
333
|
+
n8n: { requires_outcome: false, scope_paths: false, plan_approval: false },
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* Whether a harness reports tool results a gate can read a test outcome
|
|
337
|
+
* from: only where the post-tool payload was captured from
|
|
338
|
+
* the harness itself and carries an exit code or a structured error flag.
|
|
339
|
+
* The fixtures are `__tests__/fixtures/post-tool/<harness>.json`.
|
|
340
|
+
*
|
|
341
|
+
* - `claude-code`: `PostToolUse` (Bash `tool_response.stdout`/`stderr`, no
|
|
342
|
+
* exit code) and `PostToolUseFailure` (`error`, first line `Exit code N`);
|
|
343
|
+
* captured from Claude Code 2.1.233.
|
|
344
|
+
* - `gemini-cli` (the bash gate the antigravity writer installs): `AfterTool`
|
|
345
|
+
* (`tool_response.llmContent` carrying `Exit Code: N` when non-zero);
|
|
346
|
+
* captured from Gemini CLI 0.36.0.
|
|
347
|
+
*
|
|
348
|
+
* Every other harness is `false`: no payload was captured, so a qualified
|
|
349
|
+
* `requires_before` there refuses with the "not observable" reason.
|
|
350
|
+
*/
|
|
351
|
+
export const POST_TOOL_OBSERVABLE = {
|
|
352
|
+
'claude-code': true,
|
|
353
|
+
'gemini-cli': true,
|
|
354
|
+
cursor: false,
|
|
355
|
+
windsurf: false,
|
|
356
|
+
codex: false,
|
|
357
|
+
'muse-code': false,
|
|
358
|
+
'github-copilot': false,
|
|
359
|
+
cline: false,
|
|
360
|
+
grok: false,
|
|
361
|
+
antigravity: false,
|
|
362
|
+
goose: false,
|
|
363
|
+
openhands: false,
|
|
364
|
+
hermes: false,
|
|
365
|
+
openclaw: false,
|
|
366
|
+
pi: false,
|
|
367
|
+
opencode: false,
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* The approved-plans cache the daemon writes (§4.3, `approvedPlans.ts`), in
|
|
371
|
+
* the same directory as the policy snapshot. Its `#expires` header is three
|
|
372
|
+
* sync intervals after it was written; a gate reads a cache that is missing,
|
|
373
|
+
* past it, or fails its digest or workspace check as "no plan approved".
|
|
374
|
+
*/
|
|
375
|
+
export const APPROVED_PLANS_FILE = 'approved-plans.jsonl';
|
|
376
|
+
/**
|
|
377
|
+
* Whether each family of rendered gate evaluates the decide rules a snapshot
|
|
378
|
+
* carries: the JS gates, the bash gates and the n8n workflow gate. A family
|
|
379
|
+
* that does not keeps every decide rule's fallback line in force, so it
|
|
380
|
+
* refuses what the rule covers exactly as a gate that reads no `@decide`
|
|
381
|
+
* records does; it never skips a fallback line it cannot stand in for. Set a
|
|
382
|
+
* family only where its decide tier runs.
|
|
254
383
|
*/
|
|
255
|
-
export const
|
|
384
|
+
export const GATE_EVALUATES_DECIDE = { js: true, bash: true, n8n: true };
|
|
256
385
|
/**
|
|
257
386
|
* The refusal codes a hook gate's JSON decision carries, as `code` beside the
|
|
258
387
|
* deciding `ruleId` (null when no rule decided), for the harnesses that read
|
|
@@ -278,7 +407,17 @@ export const GATE_VERSION = 21;
|
|
|
278
407
|
* (`gateDeadlineMs` in `@intutic/shared-types`);
|
|
279
408
|
* - `UNREADABLE_CALL`: the payload held no tool call the gate could read;
|
|
280
409
|
* - `GATE_CRASHED`: the gate failed while deciding, or a chaos run's
|
|
281
|
-
* `INTUTIC_CHAOS_PLAN` is set but unreadable
|
|
410
|
+
* `INTUTIC_CHAOS_PLAN` is set but unreadable;
|
|
411
|
+
* - `DECIDE_REFUSED`, `DECIDE_ESCALATED`, `GOVERNANCE_UNAVAILABLE`: a decide
|
|
412
|
+
* rule refused, could not be decided where the harness cannot ask, or its
|
|
413
|
+
* engine was unavailable while it enforces.
|
|
414
|
+
* - `PREREQUISITE_UNMET`: a `requires_before: action:run_tests[ok] -> X` rule
|
|
415
|
+
* is not met (no run, a failed or unreadable one, a stale one, no session,
|
|
416
|
+
* or a harness whose results cannot be observed);
|
|
417
|
+
* - `OUT_OF_SCOPE`: a file change outside `scope_paths` under
|
|
418
|
+
* `scope_paths_mode: enforce`;
|
|
419
|
+
* - `PLAN_NOT_APPROVED`, `OFF_PLAN`: `plan_approval:` and no approved plan
|
|
420
|
+
* for the session, or (`strict`) a tool that is not one of its steps.
|
|
282
421
|
*
|
|
283
422
|
* A fault a chaos run injects into an allowed call is not a refusal by
|
|
284
423
|
* governance: its JSON decision carries `code: null` and `ruleId: null`, or
|
|
@@ -289,6 +428,10 @@ export const HOOK_REFUSAL_CODES = [
|
|
|
289
428
|
'SNAPSHOT',
|
|
290
429
|
'SSO_GROUP',
|
|
291
430
|
'HELD',
|
|
431
|
+
'PREREQUISITE_UNMET',
|
|
432
|
+
'OUT_OF_SCOPE',
|
|
433
|
+
'OFF_PLAN',
|
|
434
|
+
'PLAN_NOT_APPROVED',
|
|
292
435
|
'SERVER_BLOCKED',
|
|
293
436
|
'SERVER_HELD',
|
|
294
437
|
'SERVER_NOT_APPROVED',
|
|
@@ -299,6 +442,9 @@ export const HOOK_REFUSAL_CODES = [
|
|
|
299
442
|
'GATE_DEADLINE',
|
|
300
443
|
'UNREADABLE_CALL',
|
|
301
444
|
'GATE_CRASHED',
|
|
445
|
+
'DECIDE_REFUSED',
|
|
446
|
+
'DECIDE_ESCALATED',
|
|
447
|
+
'GOVERNANCE_UNAVAILABLE',
|
|
302
448
|
];
|
|
303
449
|
/**
|
|
304
450
|
* The coarse command → action-token classification the hold tier keys on:
|
|
@@ -316,7 +462,29 @@ export const ACTION_NEEDLES = [
|
|
|
316
462
|
['action:publish', ['npm publish', 'pnpm publish', 'yarn publish', 'cargo publish', 'twine upload', 'poetry publish', 'gem push', 'docker manifest push']],
|
|
317
463
|
['action:release', ['gh release create', 'git tag', 'npm version', 'cargo release', 'goreleaser release', 'semantic-release']],
|
|
318
464
|
['action:db_write', ['insert into', 'update ', 'delete from', 'drop table', 'drop database', 'drop schema', 'truncate ', 'alter table']],
|
|
465
|
+
['action:run_tests', ['npm test', 'npm run test', 'pnpm test', 'pnpm run test', 'yarn test', 'cargo test', 'go test', 'pytest', 'python -m pytest', 'vitest', 'jest', 'mocha', 'rspec', 'phpunit', 'make test', 'gradle test', 'mvn test', 'dotnet test', 'bazel test', 'tox']],
|
|
466
|
+
['action:http_post', ['curl -x post', 'curl --request post', 'curl -d ', 'curl --data', 'wget --post', 'http post']],
|
|
467
|
+
['action:migrate', ['prisma migrate deploy', 'prisma db push', 'alembic upgrade', 'alembic downgrade', 'rails db:migrate', 'rake db:migrate', 'manage.py migrate', 'flyway migrate', 'liquibase update', 'knex migrate:latest', 'sequelize db:migrate']],
|
|
468
|
+
['action:prod_write', ['--prod', 'rails_env=production', 'node_env=production', '--context prod', '--namespace production', '-n production', '--environment production', '--stage prod']],
|
|
469
|
+
['action:destroy', ['terraform destroy', 'kubectl delete', 'helm uninstall', 'helm delete', 'drop table', 'drop database', 'drop schema', 'truncate ', 'aws s3 rb', 'aws s3 rm']],
|
|
470
|
+
['action:payment', ['api.stripe.com/v1/charges', 'api.stripe.com/v1/payment_intents', 'api.stripe.com/v1/transfers', 'api.stripe.com/v1/payouts', 'api.stripe.com/v1/refunds', 'stripe charges create', 'stripe payment_intents create', 'stripe transfers create', 'stripe payouts create', 'api-m.paypal.com/v2/payments', 'api.paypal.com/v1/payments']],
|
|
319
471
|
];
|
|
472
|
+
/**
|
|
473
|
+
* `action:force_push` is a compound, not a needle list (§5.1): the `git push`
|
|
474
|
+
* phrase AND a whole-word ` --force ` or ` -f ` in the space-padded,
|
|
475
|
+
* whitespace-collapsed, lower-cased command. ` --force-with-lease` does not
|
|
476
|
+
* match.
|
|
477
|
+
*/
|
|
478
|
+
export const FORCE_PUSH_TOKEN = 'action:force_push';
|
|
479
|
+
export const FORCE_PUSH_FLAGS = [' --force ', ' -f '];
|
|
480
|
+
/**
|
|
481
|
+
* `action:destroy` also covers a recursive, forced `rm` whose target is
|
|
482
|
+
* outside the working tree (absolute, under `~`, or containing `..`; Q3).
|
|
483
|
+
* No phrase can say "outside the tree", so both classifiers run the
|
|
484
|
+
* predicate `removesOutsideTree` of `@intutic/gate` (`actions.ts`) after the
|
|
485
|
+
* needles; `rm -rf build/` stays unclassified.
|
|
486
|
+
*/
|
|
487
|
+
export const DESTROY_TOKEN = 'action:destroy';
|
|
320
488
|
/**
|
|
321
489
|
* The (lower-cased) tool names whose `command` the classifier reads: every
|
|
322
490
|
* harness's shell tool. The same list as the proxy's `SHELL_TOOLS` in
|
|
@@ -343,10 +511,283 @@ export const REVIEW_REQUESTS_LOG = `.intutic/events/${REVIEW_REQUESTS_BASENAME}`
|
|
|
343
511
|
/** Bumped when a hold record's shape changes; the control plane drops any
|
|
344
512
|
* other version at ingest. */
|
|
345
513
|
export const REVIEW_REQUEST_VERSION = 1;
|
|
514
|
+
/**
|
|
515
|
+
* The rule-record tiers of a bash gate, in Python over the shared
|
|
516
|
+
* ports ({@link TEST_OUTCOME_PY_SOURCE}, {@link SCOPE_MATCH_PY_SOURCE},
|
|
517
|
+
* {@link RULE_RECORD_PY_SOURCE}, {@link REFUSAL_DETAIL_PY_SOURCE}): the
|
|
518
|
+
* fallback lines a decoded record names, the `requires_outcome` check with
|
|
519
|
+
* its state bookkeeping, the trajectory check (`scope_paths`,
|
|
520
|
+
* `plan_approval`), and the post-tool recorder. A decoded record is flat:
|
|
521
|
+
* its payload fields beside `kind`, `fallbackId` and `rollout`. Each check
|
|
522
|
+
* returns "" to continue, or verdict, code, rule id, reason and the
|
|
523
|
+
* retry-scope line (§6.2; empty when the refusal names no paths or tools)
|
|
524
|
+
* joined by tabs, the verdict `block` or `shadow`.
|
|
525
|
+
*/
|
|
526
|
+
const GATE_RULE_TIERS_PY = String.raw `
|
|
527
|
+
|
|
528
|
+
def _intutic_records(records_text, kind):
|
|
529
|
+
out = []
|
|
530
|
+
for line in str(records_text or "").split("\n"):
|
|
531
|
+
r = decode_gate_rule_record(line) if line else None
|
|
532
|
+
if r is not None and r["kind"] == kind:
|
|
533
|
+
out.append(r)
|
|
534
|
+
return out
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def intutic_rule_skip(records_text, kinds_csv):
|
|
538
|
+
# The fallback line ids a decoded, enforcing record names, one per line,
|
|
539
|
+
# for the kinds this gate evaluates. Any other fallback line stays.
|
|
540
|
+
kinds = [k for k in str(kinds_csv).split(",") if k]
|
|
541
|
+
out = []
|
|
542
|
+
for line in str(records_text or "").split("\n"):
|
|
543
|
+
r = decode_gate_rule_record(line) if line else None
|
|
544
|
+
if r is not None and r["fallbackId"] and r["kind"] in kinds:
|
|
545
|
+
out.append(r["fallbackId"])
|
|
546
|
+
return "\n".join(out)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _intutic_now_ms():
|
|
550
|
+
import time
|
|
551
|
+
return int(time.time() * 1000)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _intutic_state_path(harness, session):
|
|
555
|
+
import hashlib, os
|
|
556
|
+
base = os.environ.get("INTUTIC_OUTCOME_STATE_DIR") or os.path.join(
|
|
557
|
+
os.path.expanduser("~"), ".intutic", "hooks", "state", "outcomes")
|
|
558
|
+
return os.path.join(base, outcome_state_file_name(str(harness), str(session),
|
|
559
|
+
lambda t: hashlib.sha256(t.encode("utf-8")).hexdigest()))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _intutic_state_read(path):
|
|
563
|
+
# (exists, state): a missing file is an empty state; one that does not
|
|
564
|
+
# read is None, which leaves every qualified rule unmet.
|
|
565
|
+
import os
|
|
566
|
+
if not os.path.exists(path):
|
|
567
|
+
return False, empty_outcome_state()
|
|
568
|
+
try:
|
|
569
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
570
|
+
return True, parse_outcome_state(fh.read())
|
|
571
|
+
except Exception:
|
|
572
|
+
return True, None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _intutic_state_write(path, state):
|
|
576
|
+
import json, os
|
|
577
|
+
try:
|
|
578
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
579
|
+
tmp = path + ".tmp." + str(os.getpid())
|
|
580
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
581
|
+
json.dump(state, fh, separators=(",", ":"))
|
|
582
|
+
os.replace(tmp, path)
|
|
583
|
+
except Exception:
|
|
584
|
+
pass
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _intutic_verdict(r, code, rid, reason, scope):
|
|
588
|
+
return "\t".join(["block" if r["rollout"] == "enforce" else "shadow", code, rid, " ".join(reason.split()),
|
|
589
|
+
retry_scope_line("TRANSPARENT", scope) or ""])
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def intutic_outcome_check(harness, observable, tool, actions, session, records_text, tool_input_json):
|
|
593
|
+
import json
|
|
594
|
+
try:
|
|
595
|
+
ti = json.loads(tool_input_json or "{}")
|
|
596
|
+
except ValueError:
|
|
597
|
+
ti = {}
|
|
598
|
+
recs = _intutic_records(records_text, "requires_outcome")
|
|
599
|
+
path = _intutic_state_path(harness, session) if session else None
|
|
600
|
+
exists, state = _intutic_state_read(path) if path else (False, empty_outcome_state())
|
|
601
|
+
shadow = ""
|
|
602
|
+
for r in recs:
|
|
603
|
+
then = r["then"]
|
|
604
|
+
if (" " + then + " ") not in str(actions) and then.lower() != str(tool).lower():
|
|
605
|
+
continue
|
|
606
|
+
why = None
|
|
607
|
+
if observable != "1":
|
|
608
|
+
case = "not_observable"
|
|
609
|
+
elif not session:
|
|
610
|
+
case = "no_session"
|
|
611
|
+
else:
|
|
612
|
+
case, why = prerequisite_case(state, r["first"], r["adjacent"], _intutic_now_ms())
|
|
613
|
+
if case == "met":
|
|
614
|
+
continue
|
|
615
|
+
rid = "requires_before." + r["first"] + "[ok]" + ("~>" if r["adjacent"] else "->") + then
|
|
616
|
+
line = _intutic_verdict(r, "PREREQUISITE_UNMET", rid, prerequisite_unmet_reason(r["first"], then, case, why, harness),
|
|
617
|
+
{"tools": [then], "step": r["first"]})
|
|
618
|
+
if r["rollout"] == "enforce":
|
|
619
|
+
return line
|
|
620
|
+
shadow = shadow or line
|
|
621
|
+
# Bookkeeping, on a call this tier lets through: no outcome is adjacent
|
|
622
|
+
# to what comes next any more, and a call that changes files (a mutation
|
|
623
|
+
# tool, or a shell write such as sed -i or git checkout) makes a pass
|
|
624
|
+
# before it stale (Q1).
|
|
625
|
+
if path and exists and state is not None:
|
|
626
|
+
_intutic_state_write(path, note_completed_call(state, is_file_write_call(tool, ti)))
|
|
627
|
+
return shadow
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def intutic_post_record(harness, payload_text):
|
|
631
|
+
# A post-tool payload: a shell call that ran tests records its outcome;
|
|
632
|
+
# a file change marks an earlier [ok] stale. Never refuses.
|
|
633
|
+
import json
|
|
634
|
+
try:
|
|
635
|
+
d = json.loads(payload_text or "{}")
|
|
636
|
+
except ValueError:
|
|
637
|
+
return ""
|
|
638
|
+
if not isinstance(d, dict):
|
|
639
|
+
return ""
|
|
640
|
+
session = str(d.get("session_id") or d.get("sessionId") or "")
|
|
641
|
+
if not session:
|
|
642
|
+
return ""
|
|
643
|
+
tool = str(d.get("tool_name") or d.get("toolName") or "")
|
|
644
|
+
ti = d.get("tool_input") if isinstance(d.get("tool_input"), dict) else {}
|
|
645
|
+
command = ""
|
|
646
|
+
for k in ("command", "cmd", "script", "shell_command"):
|
|
647
|
+
if isinstance(ti.get(k), str) and ti.get(k):
|
|
648
|
+
command = ti[k]
|
|
649
|
+
break
|
|
650
|
+
actions = intutic_actions(tool, command)
|
|
651
|
+
path = _intutic_state_path(harness, session)
|
|
652
|
+
exists, state = _intutic_state_read(path)
|
|
653
|
+
if " action:run_tests " in actions:
|
|
654
|
+
resp = d.get("tool_response")
|
|
655
|
+
output = ""
|
|
656
|
+
if isinstance(resp, dict):
|
|
657
|
+
v = resp.get("llmContent")
|
|
658
|
+
output = v if isinstance(v, str) else json.dumps(v)
|
|
659
|
+
# Gemini CLI prefixes the command's output with "Output: ".
|
|
660
|
+
if output.startswith("Output: "):
|
|
661
|
+
output = output[len("Output: "):]
|
|
662
|
+
elif isinstance(resp, str):
|
|
663
|
+
output = resp
|
|
664
|
+
decision = decide_test_outcome({"exitCode": extract_exit_code(output), "output": output})
|
|
665
|
+
_intutic_state_write(path, record_test_outcome(state if state is not None else empty_outcome_state(),
|
|
666
|
+
"action:run_tests", decision, _intutic_now_ms()))
|
|
667
|
+
elif exists and state is not None and is_file_write_call(tool, ti):
|
|
668
|
+
_intutic_state_write(path, note_completed_call(state, True))
|
|
669
|
+
return ""
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _intutic_real(path):
|
|
673
|
+
import os
|
|
674
|
+
return os.path.realpath(path)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _intutic_repo_root(cwd):
|
|
678
|
+
import os
|
|
679
|
+
d = _intutic_real(cwd or os.getcwd())
|
|
680
|
+
while True:
|
|
681
|
+
if os.path.exists(os.path.join(d, ".git")):
|
|
682
|
+
return d
|
|
683
|
+
parent = os.path.dirname(d)
|
|
684
|
+
if parent == d:
|
|
685
|
+
return _intutic_real(cwd or os.getcwd())
|
|
686
|
+
d = parent
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _intutic_approved_plan(session, workspace_id):
|
|
690
|
+
import hashlib, json, os, datetime
|
|
691
|
+
path = os.environ.get("INTUTIC_APPROVED_PLANS") or os.path.join(
|
|
692
|
+
os.path.expanduser("~"), ".intutic", "hooks", "${APPROVED_PLANS_FILE}")
|
|
693
|
+
try:
|
|
694
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
695
|
+
lines = fh.read().split("\n")
|
|
696
|
+
except Exception:
|
|
697
|
+
return None
|
|
698
|
+
digest = ws = expires = ""
|
|
699
|
+
body = []
|
|
700
|
+
for line in lines:
|
|
701
|
+
if line.startswith("#digest "):
|
|
702
|
+
digest = line[8:].strip()
|
|
703
|
+
elif line.startswith("#workspace "):
|
|
704
|
+
ws = line[11:].strip()
|
|
705
|
+
elif line.startswith("#expires "):
|
|
706
|
+
expires = line[9:].strip()
|
|
707
|
+
elif line and not line.startswith("#"):
|
|
708
|
+
body.append(line)
|
|
709
|
+
if hashlib.sha256("\n".join(body).encode("utf-8")).hexdigest()[:32] != digest:
|
|
710
|
+
return None
|
|
711
|
+
if ws and workspace_id and ws != workspace_id:
|
|
712
|
+
return None
|
|
713
|
+
try:
|
|
714
|
+
exp = datetime.datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
|
715
|
+
except Exception:
|
|
716
|
+
return None
|
|
717
|
+
if datetime.datetime.now(datetime.timezone.utc) >= exp:
|
|
718
|
+
return None
|
|
719
|
+
tools = None
|
|
720
|
+
for line in body:
|
|
721
|
+
try:
|
|
722
|
+
e = json.loads(line)
|
|
723
|
+
except ValueError:
|
|
724
|
+
continue
|
|
725
|
+
if not isinstance(e, dict) or e.get("sessionId") != session or e.get("state") not in ("APPROVED", "EXECUTING"):
|
|
726
|
+
continue
|
|
727
|
+
tools = (tools or []) + [str(t).lower() for t in (e.get("tools") or []) if isinstance(t, str)]
|
|
728
|
+
return None if tools is None else {"tools": tools}
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def intutic_trajectory_check(harness, tool, session, records_text, tool_input_json, payload_cwd, workspace_id):
|
|
732
|
+
import json, os
|
|
733
|
+
try:
|
|
734
|
+
ti = json.loads(tool_input_json or "{}")
|
|
735
|
+
except ValueError:
|
|
736
|
+
ti = {}
|
|
737
|
+
shadow = ""
|
|
738
|
+
scopes = _intutic_records(records_text, "scope_paths")
|
|
739
|
+
targets = [t["target"] for t in change_targets(tool, ti)] if scopes else []
|
|
740
|
+
if targets:
|
|
741
|
+
cwd = payload_cwd or os.getcwd()
|
|
742
|
+
root = _intutic_repo_root(cwd)
|
|
743
|
+
rels = []
|
|
744
|
+
for t in targets:
|
|
745
|
+
absolute = _intutic_real(os.path.join(_intutic_real(cwd), t))
|
|
746
|
+
rel = os.path.relpath(absolute, root)
|
|
747
|
+
rels.append((t, None if rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel) else rel.replace(os.sep, "/")))
|
|
748
|
+
for rollout in ("enforce", "shadow"):
|
|
749
|
+
group = [r for r in scopes if r["rollout"] == rollout]
|
|
750
|
+
if not group:
|
|
751
|
+
continue
|
|
752
|
+
union = [s for r in group for s in r["scopePaths"]]
|
|
753
|
+
bad = [t for t, rel in rels if rel is None or not is_within_scope(rel, union)]
|
|
754
|
+
if bad:
|
|
755
|
+
r = group[0]
|
|
756
|
+
line = _intutic_verdict(r, "OUT_OF_SCOPE", "scope_paths." + r["sopTitle"],
|
|
757
|
+
", ".join(bad) + " is outside scope_paths (" + ", ".join(union) + ")", {"paths": bad})
|
|
758
|
+
if rollout == "enforce":
|
|
759
|
+
return line
|
|
760
|
+
shadow = shadow or line
|
|
761
|
+
plans = _intutic_records(records_text, "plan_approval")
|
|
762
|
+
if plans and not is_plan_exempt_tool(str(tool)):
|
|
763
|
+
for rollout in ("enforce", "shadow"):
|
|
764
|
+
group = [r for r in plans if r["rollout"] == rollout]
|
|
765
|
+
if not group:
|
|
766
|
+
continue
|
|
767
|
+
strict = [r for r in group if r["mode"] == "strict"]
|
|
768
|
+
r = strict[0] if strict else group[0]
|
|
769
|
+
level = r["mode"]
|
|
770
|
+
rid = "plan_approval." + r["sopTitle"]
|
|
771
|
+
line = ""
|
|
772
|
+
if not session:
|
|
773
|
+
line = _intutic_verdict(r, "PLAN_NOT_APPROVED", rid, "plan_approval: " + level + " - " + harness + " sent no session id, so no approved plan can be found for this call", {"tools": [str(tool)]})
|
|
774
|
+
else:
|
|
775
|
+
plan = _intutic_approved_plan(session, workspace_id)
|
|
776
|
+
if plan is None:
|
|
777
|
+
line = _intutic_verdict(r, "PLAN_NOT_APPROVED", rid, "plan_approval: " + level + " - this session has no APPROVED or EXECUTING plan; get its plan approved, then retry", {"tools": [str(tool)]})
|
|
778
|
+
elif level == "strict" and str(tool).lower() not in plan["tools"]:
|
|
779
|
+
line = _intutic_verdict(r, "OFF_PLAN", rid, "plan_approval: strict - " + str(tool) + " is not a step of the approved plan", {"tools": [str(tool)]})
|
|
780
|
+
if line and rollout == "enforce":
|
|
781
|
+
return line
|
|
782
|
+
shadow = shadow or line
|
|
783
|
+
return shadow
|
|
784
|
+
`;
|
|
346
785
|
/**
|
|
347
786
|
* The Python the bash gates run, defined once per gate script: the phrase
|
|
348
787
|
* matcher ({@link PHRASES_PY_SOURCE}), the MCP registry decision
|
|
349
|
-
* ({@link MCP_REGISTRY_PY_SOURCE})
|
|
788
|
+
* ({@link MCP_REGISTRY_PY_SOURCE}), the shared rule-record ports (test
|
|
789
|
+
* outcomes, scope match, the `@rule` decoder and refusal detail, emitted
|
|
790
|
+
* from `intutic_clawde/gate/`), and entry points over them.
|
|
350
791
|
*
|
|
351
792
|
* - `intutic_actions(tool, command)` — the hold classifier, called by
|
|
352
793
|
* {@link SHELL_EXTRACT}'s Python, which already reads the raw tool input:
|
|
@@ -368,8 +809,56 @@ export const GATE_PY_LIB = `${PHRASES_PY_SOURCE}
|
|
|
368
809
|
|
|
369
810
|
${MCP_REGISTRY_PY_SOURCE}
|
|
370
811
|
|
|
812
|
+
${TEST_OUTCOME_PY_SOURCE}
|
|
813
|
+
|
|
814
|
+
${SCOPE_MATCH_PY_SOURCE}
|
|
815
|
+
|
|
816
|
+
# The lane phrases rule_record.py names (lanes.ts LANE_PHRASES).
|
|
817
|
+
LANE_PHRASES = ${JSON.stringify(LANE_PHRASES)}
|
|
818
|
+
|
|
819
|
+
${RULE_RECORD_PY_SOURCE}
|
|
820
|
+
|
|
821
|
+
${REFUSAL_DETAIL_PY_SOURCE}
|
|
822
|
+
|
|
371
823
|
_INTUTIC_ACTION_NEEDLES = ${JSON.stringify(ACTION_NEEDLES)}
|
|
372
824
|
_INTUTIC_ACTION_TOOLS = ${JSON.stringify(ACTION_TOOL_NAMES)}
|
|
825
|
+
_INTUTIC_FORCE_FLAGS = ${JSON.stringify(FORCE_PUSH_FLAGS)}
|
|
826
|
+
|
|
827
|
+
_INTUTIC_RM_SEGMENT = re.compile(r"[;&|()\\n]")
|
|
828
|
+
_INTUTIC_RM_GAP = re.compile(r"[ \\t\\r\\f\\v]+")
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _intutic_removes_outside_tree(args):
|
|
832
|
+
# removes_outside_tree (intutic_clawde actions.py, gate-js actions.ts):
|
|
833
|
+
# rm -rf / -fr / -r -f / --recursive --force with a target that is
|
|
834
|
+
# absolute, under ~, or contains "..". rm -rf build/ is not one.
|
|
835
|
+
for segment in _INTUTIC_RM_SEGMENT.split(args):
|
|
836
|
+
words = [w for w in _INTUTIC_RM_GAP.split(segment) if w != ""]
|
|
837
|
+
at = next((i for i, w in enumerate(words) if w == "rm" or w.endswith("/rm")), -1)
|
|
838
|
+
if at == -1:
|
|
839
|
+
continue
|
|
840
|
+
recursive = force = False
|
|
841
|
+
flags = True
|
|
842
|
+
targets = []
|
|
843
|
+
for w in words[at + 1:]:
|
|
844
|
+
if flags and w == "--":
|
|
845
|
+
flags = False
|
|
846
|
+
elif flags and w.startswith("--"):
|
|
847
|
+
if w == "--recursive":
|
|
848
|
+
recursive = True
|
|
849
|
+
if w == "--force":
|
|
850
|
+
force = True
|
|
851
|
+
elif flags and w.startswith("-") and len(w) > 1:
|
|
852
|
+
lower = w.lower()
|
|
853
|
+
if "r" in lower:
|
|
854
|
+
recursive = True
|
|
855
|
+
if "f" in lower:
|
|
856
|
+
force = True
|
|
857
|
+
else:
|
|
858
|
+
targets.append(w.strip("\\"'"))
|
|
859
|
+
if recursive and force and any(t.startswith("/") or t.startswith("~") or ".." in t for t in targets):
|
|
860
|
+
return True
|
|
861
|
+
return False
|
|
373
862
|
|
|
374
863
|
|
|
375
864
|
def intutic_actions(tool, command):
|
|
@@ -380,8 +869,17 @@ def intutic_actions(tool, command):
|
|
|
380
869
|
for action, needles in _INTUTIC_ACTION_NEEDLES:
|
|
381
870
|
if any(has_phrase(words, n) for n in needles):
|
|
382
871
|
out += action + " "
|
|
872
|
+
# A recursive, forced remove outside the working tree is destroy too
|
|
873
|
+
# (Q3); no phrase can say "outside the tree", so it is a predicate.
|
|
874
|
+
if " ${DESTROY_TOKEN} " not in out and _intutic_removes_outside_tree(str(command or "")):
|
|
875
|
+
out += "${DESTROY_TOKEN} "
|
|
876
|
+
# The force-push compound: the git push phrase and a whole-word flag.
|
|
877
|
+
if has_phrase(words, "git push"):
|
|
878
|
+
padded = " " + " ".join(str(command or "").lower().split()) + " "
|
|
879
|
+
if any(f in padded for f in _INTUTIC_FORCE_FLAGS):
|
|
880
|
+
out += "${FORCE_PUSH_TOKEN} "
|
|
383
881
|
return out
|
|
384
|
-
|
|
882
|
+
${GATE_RULE_TIERS_PY}
|
|
385
883
|
|
|
386
884
|
def intutic_phrase_rule(source, command):
|
|
387
885
|
words = phrase_text(command)
|
|
@@ -435,6 +933,63 @@ def intutic_chaos_call(tool, decision, rule_id, destructive, policy_hit, key="")
|
|
|
435
933
|
if fault is None:
|
|
436
934
|
return ""
|
|
437
935
|
return fault["kind"] + "\\t" + fault["reason"]
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
_INTUTIC_DECIDE_CODES = {
|
|
939
|
+
"allow": (None,),
|
|
940
|
+
"refuse": ("DECIDE_REFUSED", "DECIDE_ESCALATED", "GOVERNANCE_UNAVAILABLE"),
|
|
941
|
+
"escalate": ("DECIDE_ESCALATED",),
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def intutic_decide_check(harness, tool, budget_ms, session_id, enforce, tool_input_json):
|
|
946
|
+
# The decide tier of a bash gate: \`intutic decide check --stdin\` on a call
|
|
947
|
+
# an @decide record names. Returns outcome, code, rule id and reason,
|
|
948
|
+
# joined by the unit separator (an empty field where there is none). A
|
|
949
|
+
# missing CLI, a non-zero exit, a timeout or an answer that does not read
|
|
950
|
+
# is unavailable: refused with GOVERNANCE_UNAVAILABLE when a matched
|
|
951
|
+
# record is stamped enforce (enforce "1"), allowed, with the reason, when
|
|
952
|
+
# every one is shadow. The CLI spends the budget, its own start included.
|
|
953
|
+
import json, shutil, subprocess
|
|
954
|
+
sep = chr(31)
|
|
955
|
+
|
|
956
|
+
def clean(text):
|
|
957
|
+
return " ".join(str(text).split())
|
|
958
|
+
|
|
959
|
+
def unavailable(why):
|
|
960
|
+
if enforce != "1":
|
|
961
|
+
return sep.join(["allow", "", "", clean(why)])
|
|
962
|
+
return sep.join(["refuse", "GOVERNANCE_UNAVAILABLE", "", clean("GOVERNANCE_UNAVAILABLE — " + why)])
|
|
963
|
+
|
|
964
|
+
try:
|
|
965
|
+
tool_input = json.loads(tool_input_json or "{}")
|
|
966
|
+
except ValueError:
|
|
967
|
+
tool_input = {}
|
|
968
|
+
exe = shutil.which("intutic")
|
|
969
|
+
if not exe:
|
|
970
|
+
return unavailable("the decide rules on " + tool + " need the intutic CLI, which is not on PATH")
|
|
971
|
+
payload = {"v": 1, "harness": harness, "tool": tool, "toolInput": tool_input, "cwd": os.getcwd(),
|
|
972
|
+
"sessionId": session_id, "budgetMs": int(budget_ms), "lastUserTurn": None}
|
|
973
|
+
try:
|
|
974
|
+
done = subprocess.run([exe, "decide", "check", "--stdin"], input=json.dumps(payload), capture_output=True,
|
|
975
|
+
text=True, timeout=int(budget_ms) / 1000.0)
|
|
976
|
+
except subprocess.TimeoutExpired:
|
|
977
|
+
return unavailable("intutic decide check did not answer within " + str(budget_ms) + " ms")
|
|
978
|
+
except OSError as err:
|
|
979
|
+
return unavailable("intutic decide check could not run (" + type(err).__name__ + ")")
|
|
980
|
+
if done.returncode != 0:
|
|
981
|
+
return unavailable("intutic decide check exited " + str(done.returncode))
|
|
982
|
+
try:
|
|
983
|
+
out = json.loads(done.stdout)
|
|
984
|
+
except ValueError:
|
|
985
|
+
out = None
|
|
986
|
+
rule_id = out.get("ruleId") if isinstance(out, dict) else None
|
|
987
|
+
if (not isinstance(out, dict) or out.get("v") != 1 or out.get("outcome") not in _INTUTIC_DECIDE_CODES
|
|
988
|
+
or out.get("code") not in _INTUTIC_DECIDE_CODES[out.get("outcome")]
|
|
989
|
+
or not (rule_id is None or (isinstance(rule_id, str) and rule_id.startswith("decide.")))
|
|
990
|
+
or not isinstance(out.get("reason", ""), str)):
|
|
991
|
+
return unavailable("intutic decide check gave an answer this gate does not read")
|
|
992
|
+
return sep.join([out["outcome"], out.get("code") or "", clean(rule_id or ""), clean(out.get("reason") or "")])
|
|
438
993
|
`;
|
|
439
994
|
/**
|
|
440
995
|
* How old a snapshot may be before a gate reports it as stale.
|
|
@@ -445,6 +1000,18 @@ def intutic_chaos_call(tool, decision, rule_id, destructive, policy_hit, key="")
|
|
|
445
1000
|
* this fires on "the daemon has been dead for a week", not on "it is Sunday".
|
|
446
1001
|
*/
|
|
447
1002
|
export const SNAPSHOT_STALE_AFTER_DAYS = 7;
|
|
1003
|
+
/**
|
|
1004
|
+
* The newest snapshot format a v23 gate reads: 3, the `@rule` records' (LLD
|
|
1005
|
+
* #75). The shared `SNAPSHOT_FORMAT` names the decide records' 2 until the
|
|
1006
|
+
* shared modules move it.
|
|
1007
|
+
*/
|
|
1008
|
+
export const GATE_READS_FORMAT = Math.max(SNAPSHOT_FORMAT, RULE_SNAPSHOT_FORMAT);
|
|
1009
|
+
/**
|
|
1010
|
+
* What a gate reports, once per call, on a snapshot in a format newer than it
|
|
1011
|
+
* reads (`snapshot_format_newer`); `{n}` is the snapshot's format. It still
|
|
1012
|
+
* enforces every line it reads, and a fallback line of a rule it cannot read.
|
|
1013
|
+
*/
|
|
1014
|
+
export const FORMAT_NEWER_MESSAGE = `Policy snapshot format {n} is newer than this gate reads (${GATE_READS_FORMAT}) — records it does not know are skipped and their fallback lines enforced`;
|
|
448
1015
|
/** Single-quotes a string for shell, safely. */
|
|
449
1016
|
function shq(s) {
|
|
450
1017
|
return "'" + s.replace(/'/g, `'\\''`) + "'";
|
|
@@ -573,6 +1140,12 @@ export function emitShellFailClosed(harness) {
|
|
|
573
1140
|
# "hook errored" and run the tool anyway, so a crash is converted into a block.
|
|
574
1141
|
# EXIT rather than ERR: ERR is unreliable across shells for set -u deaths, and
|
|
575
1142
|
# it fires on benign non-zero intermediates; the final status cannot lie.
|
|
1143
|
+
# A post-tool invocation (--event post, gate body v23) records a tool's result
|
|
1144
|
+
# and never refuses: the tool has already run, and some harnesses read a
|
|
1145
|
+
# post-tool exit 2 as text to feed back to the model. So in post mode every
|
|
1146
|
+
# exit, a crash and the deadline included, is 0.
|
|
1147
|
+
INTUTIC_POST_MODE=0
|
|
1148
|
+
if [ "\${1:-}" = "--event" ] && [ "\${2:-}" = "post" ]; then INTUTIC_POST_MODE=1; fi
|
|
576
1149
|
intutic_fail_closed() {
|
|
577
1150
|
_intutic_exit_rc=$?
|
|
578
1151
|
# The trap must not itself die on an unset variable or a failed command —
|
|
@@ -580,6 +1153,7 @@ intutic_fail_closed() {
|
|
|
580
1153
|
set +eu
|
|
581
1154
|
trap - EXIT
|
|
582
1155
|
[ -n "\${INTUTIC_WATCHDOG_PID:-}" ] && kill "$INTUTIC_WATCHDOG_PID" 2>/dev/null
|
|
1156
|
+
if [ "\${INTUTIC_POST_MODE:-0}" = "1" ]; then exit 0; fi
|
|
583
1157
|
# A chaos run's record of a call the gate refused, held or crashed on (the
|
|
584
1158
|
# gate body records an allowed one where it ends). It cannot change the
|
|
585
1159
|
# exit status: everything it prints is discarded.
|
|
@@ -606,6 +1180,7 @@ trap intutic_fail_closed EXIT
|
|
|
606
1180
|
# linear; a workspace's own WHERE pattern need not be.
|
|
607
1181
|
intutic_deadline() {
|
|
608
1182
|
trap - USR1
|
|
1183
|
+
if [ "\${INTUTIC_POST_MODE:-0}" = "1" ]; then exit 0; fi
|
|
609
1184
|
echo "[Intutic Governance] BLOCKED: GATE_DEADLINE — the gate did not finish evaluating this call within ${deadlineS} s, and refuses it rather than let the hook timeout allow it." >&2
|
|
610
1185
|
if command -v log_event >/dev/null 2>&1; then
|
|
611
1186
|
log_event "tool_blocked" "\${TOOL:-unknown}" "GATE_DEADLINE — the gate did not finish within ${deadlineS} s" || true
|
|
@@ -639,6 +1214,46 @@ export function emitJsActionClassifier() {
|
|
|
639
1214
|
${PHRASES_JS_SOURCE}
|
|
640
1215
|
const INTUTIC_ACTION_NEEDLES = ${JSON.stringify(ACTION_NEEDLES)};
|
|
641
1216
|
const INTUTIC_ACTION_TOOLS = ${JSON.stringify(ACTION_TOOL_NAMES)};
|
|
1217
|
+
const INTUTIC_FORCE_FLAGS = ${JSON.stringify(FORCE_PUSH_FLAGS)};
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* removesOutsideTree (@intutic/gate actions.ts): rm -rf / -fr / -r -f /
|
|
1221
|
+
* --recursive --force with a target that is absolute, under ~, or contains
|
|
1222
|
+
* "..". Segments split at ; & | ( ) and newlines; linear in the command.
|
|
1223
|
+
*/
|
|
1224
|
+
function intuticRemovesOutsideTree(args) {
|
|
1225
|
+
var segments = args.split(/[;&|()\\n]/);
|
|
1226
|
+
for (var s = 0; s < segments.length; s++) {
|
|
1227
|
+
var words = segments[s].split(/[ \\t\\r\\f\\v]+/).filter(function (w) { return w !== ''; });
|
|
1228
|
+
var at = -1;
|
|
1229
|
+
for (var i = 0; i < words.length; i++) {
|
|
1230
|
+
if (words[i] === 'rm' || words[i].slice(-3) === '/rm') { at = i; break; }
|
|
1231
|
+
}
|
|
1232
|
+
if (at === -1) continue;
|
|
1233
|
+
var recursive = false, force = false, flags = true, targets = [];
|
|
1234
|
+
for (var j = at + 1; j < words.length; j++) {
|
|
1235
|
+
var w = words[j];
|
|
1236
|
+
if (flags && w === '--') flags = false;
|
|
1237
|
+
else if (flags && w.indexOf('--') === 0) {
|
|
1238
|
+
if (w === '--recursive') recursive = true;
|
|
1239
|
+
if (w === '--force') force = true;
|
|
1240
|
+
} else if (flags && w.charAt(0) === '-' && w.length > 1) {
|
|
1241
|
+
var lower = w.toLowerCase();
|
|
1242
|
+
if (lower.indexOf('r') !== -1) recursive = true;
|
|
1243
|
+
if (lower.indexOf('f') !== -1) force = true;
|
|
1244
|
+
} else {
|
|
1245
|
+
var a = 0, b = w.length;
|
|
1246
|
+
while (a < b && (w.charAt(a) === '"' || w.charAt(a) === "'")) a++;
|
|
1247
|
+
while (b > a && (w.charAt(b - 1) === '"' || w.charAt(b - 1) === "'")) b--;
|
|
1248
|
+
targets.push(w.slice(a, b));
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
for (var t = 0; t < targets.length; t++) {
|
|
1252
|
+
if (recursive && force && (targets[t].charAt(0) === '/' || targets[t].charAt(0) === '~' || targets[t].indexOf('..') !== -1)) return true;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
642
1257
|
|
|
643
1258
|
/** Space-padded action tokens for a shell command (" action:deploy "), or " ". */
|
|
644
1259
|
function intuticActions(toolName, command) {
|
|
@@ -650,6 +1265,15 @@ function intuticActions(toolName, command) {
|
|
|
650
1265
|
if (hasPhrase(words, needles[j])) { out += INTUTIC_ACTION_NEEDLES[i][0] + ' '; break; }
|
|
651
1266
|
}
|
|
652
1267
|
}
|
|
1268
|
+
// A recursive, forced remove outside the working tree is destroy too (Q3).
|
|
1269
|
+
if (out.indexOf(' ${DESTROY_TOKEN} ') === -1 && intuticRemovesOutsideTree(String(command == null ? '' : command))) out += '${DESTROY_TOKEN} ';
|
|
1270
|
+
// The force-push compound: the git push phrase and a whole-word flag.
|
|
1271
|
+
if (hasPhrase(words, 'git push')) {
|
|
1272
|
+
var padded = ' ' + String(command == null ? '' : command).toLowerCase().split(/\\s+/).filter(Boolean).join(' ') + ' ';
|
|
1273
|
+
for (var f = 0; f < INTUTIC_FORCE_FLAGS.length; f++) {
|
|
1274
|
+
if (padded.indexOf(INTUTIC_FORCE_FLAGS[f]) !== -1) { out += '${FORCE_PUSH_TOKEN} '; break; }
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
653
1277
|
return out;
|
|
654
1278
|
}
|
|
655
1279
|
|
|
@@ -803,9 +1427,173 @@ const SCREEN_SUBJECTS = [
|
|
|
803
1427
|
['content', 'TOOL_INPUT_JSON'],
|
|
804
1428
|
['action', 'INTUTIC_ACTIONS'],
|
|
805
1429
|
];
|
|
1430
|
+
/**
|
|
1431
|
+
* The bash gate's handling of decide fallback lines. A gate that evaluates
|
|
1432
|
+
* decide rules (through the CLI) skips the fallback line an `@decide` record
|
|
1433
|
+
* names; one that does not keeps every fallback line, which refuses.
|
|
1434
|
+
*/
|
|
1435
|
+
function shellDecideFallbackSkip(evaluatesDecide) {
|
|
1436
|
+
if (!evaluatesDecide) {
|
|
1437
|
+
return ` # A fallback line stands in for a decide rule a gate cannot evaluate. This
|
|
1438
|
+
# gate evaluates none, so every fallback line stays in force and refuses
|
|
1439
|
+
# its rule's tools, as in a gate that reads no @decide records.
|
|
1440
|
+
|
|
1441
|
+
`;
|
|
1442
|
+
}
|
|
1443
|
+
return ` # A fallback line stands in for a decide rule a gate cannot evaluate. This
|
|
1444
|
+
# gate hands a decide rule to the CLI, which decodes its record and refuses
|
|
1445
|
+
# the call when it does not decode, so it skips the fallback line that an
|
|
1446
|
+
# @decide record of five columns names in its second. Any other line with
|
|
1447
|
+
# the prefix is enforced.
|
|
1448
|
+
if [ \${#INTUTIC_DECIDE_RECORDS[@]} -gt 0 ]; then
|
|
1449
|
+
_intutic_skip=$'\\n'
|
|
1450
|
+
for _rec in "\${INTUTIC_DECIDE_RECORDS[@]}"; do
|
|
1451
|
+
_intutic_tabs="\${_rec//[^$'\\t']/}"
|
|
1452
|
+
_intutic_fb="\${_rec#*$'\\t'}"
|
|
1453
|
+
_intutic_fb="\${_intutic_fb%%$'\\t'*}"
|
|
1454
|
+
if [ \${#_intutic_tabs} -eq 4 ] && [ -n "\${_rec##*$'\\t'}" ]; then
|
|
1455
|
+
case "$_intutic_fb" in '${DECIDE_FALLBACK_PREFIX}'*) _intutic_skip+="$_intutic_fb"$'\\n' ;; esac
|
|
1456
|
+
fi
|
|
1457
|
+
done
|
|
1458
|
+
_intutic_kept=()
|
|
1459
|
+
if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
|
|
1460
|
+
for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
|
|
1461
|
+
case "$_intutic_skip" in *$'\\n'"\${_rec%%$'\\t'*}"$'\\n'*) continue ;; esac
|
|
1462
|
+
_intutic_kept+=("$_rec")
|
|
1463
|
+
done
|
|
1464
|
+
fi
|
|
1465
|
+
INTUTIC_DYNAMIC=()
|
|
1466
|
+
if [ \${#_intutic_kept[@]} -gt 0 ]; then INTUTIC_DYNAMIC=("\${_intutic_kept[@]}"); fi
|
|
1467
|
+
fi
|
|
1468
|
+
|
|
1469
|
+
`;
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* The bash gate's handling of `<kind>-fallback:` lines (gate body v23): the
|
|
1473
|
+
* extractor's Python decodes the `@rule` records and names the fallback
|
|
1474
|
+
* lines of the decoded, enforcing ones whose kind this gate evaluates; those
|
|
1475
|
+
* are dropped. Any other fallback line is enforced.
|
|
1476
|
+
*/
|
|
1477
|
+
function shellRuleFallbackSkip(kindsCsv) {
|
|
1478
|
+
return ` # A <kind>-fallback: line stands in for a rule record a gate cannot
|
|
1479
|
+
# evaluate. This gate evaluates ${kindsCsv || 'none'}, so it drops the fallback line a
|
|
1480
|
+
# decoded record of those kinds names. A python3 that cannot say keeps them all.
|
|
1481
|
+
if [ \${#INTUTIC_RULE_RECORDS[@]} -gt 0 ] && [ -n ${shq(kindsCsv)} ]; then
|
|
1482
|
+
_intutic_rskip="$(python3 -c 'import os, sys
|
|
1483
|
+
lib = {}
|
|
1484
|
+
exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
|
|
1485
|
+
sys.stdout.write(lib["intutic_rule_skip"](sys.stdin.read(), sys.argv[1]))' ${shq(kindsCsv)} <<< "$(printf '%s\\n' "\${INTUTIC_RULE_RECORDS[@]}")" 2>/dev/null || true)"
|
|
1486
|
+
if [ -n "$_intutic_rskip" ] && [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
|
|
1487
|
+
_intutic_rskip=$'\\n'"$_intutic_rskip"$'\\n'
|
|
1488
|
+
_intutic_kept=()
|
|
1489
|
+
for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
|
|
1490
|
+
case "$_intutic_rskip" in *$'\\n'"\${_rec%%$'\\t'*}"$'\\n'*) continue ;; esac
|
|
1491
|
+
_intutic_kept+=("$_rec")
|
|
1492
|
+
done
|
|
1493
|
+
INTUTIC_DYNAMIC=()
|
|
1494
|
+
if [ \${#_intutic_kept[@]} -gt 0 ]; then INTUTIC_DYNAMIC=("\${_intutic_kept[@]}"); fi
|
|
1495
|
+
fi
|
|
1496
|
+
fi
|
|
1497
|
+
|
|
1498
|
+
`;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* The bash gate's decide tier, after every deterministic tier allowed the
|
|
1502
|
+
* call: a call whose tool an `@decide` record names (column 4) goes to
|
|
1503
|
+
* `intutic decide check --stdin` through `intutic_decide_check` in
|
|
1504
|
+
* {@link GATE_PY_LIB}, within the harness's budget (decideBudgetMs in gateLimits.ts), capped
|
|
1505
|
+
* by what the deadline has left. The CLI decodes the records, so a record that
|
|
1506
|
+
* does not decode refuses there.
|
|
1507
|
+
*/
|
|
1508
|
+
function shellDecideTier(opts) {
|
|
1509
|
+
const log = opts.logFn ?? 'log_event';
|
|
1510
|
+
const ask = opts.ask ?? 'none';
|
|
1511
|
+
if (ask !== 'none' && ask !== 'hermes-approve')
|
|
1512
|
+
throw new Error(`gateBody: the ${ask} ask contract is not one a bash gate speaks`);
|
|
1513
|
+
const askBlock = ask === 'hermes-approve'
|
|
1514
|
+
? ` if [ "$_intutic_dec_out" = "escalate" ]; then
|
|
1515
|
+
# Hermes' human approval: {"action":"approve"}. Never {"decision":"approve"},
|
|
1516
|
+
# which Hermes reads as an allow.
|
|
1517
|
+
intutic_shown "$_intutic_dec_full"
|
|
1518
|
+
echo "[Intutic Governance] ASK: \${_intutic_shown}" >&2
|
|
1519
|
+
${log} "tool_flagged" "\${TOOL:-}" "DECIDE_ESCALATED — asked a person: $_intutic_dec_full" || true
|
|
1520
|
+
_intutic_chaos_rule="$_intutic_dec_rid"; _intutic_chaos_hit=1
|
|
1521
|
+
intutic_chaos_decide hold >/dev/null 2>&1 || true
|
|
1522
|
+
_intutic_chaos_done=1
|
|
1523
|
+
printf '{"action":"approve","message":"%s"}\\n' "$(intutic_json_escape "$_intutic_shown")"
|
|
1524
|
+
exit 0
|
|
1525
|
+
fi
|
|
1526
|
+
`
|
|
1527
|
+
: ` # ${opts.harness} cannot ask a person, so an escalation refuses (DECIDE_ESCALATED).
|
|
1528
|
+
`;
|
|
1529
|
+
return `# ── Decide tier (gate body v22) ──────────────────────────────────────────────
|
|
1530
|
+
# Only after every tier above allowed the call, and only for a tool an @decide
|
|
1531
|
+
# record names in its fourth column: intutic decide check --stdin asks the
|
|
1532
|
+
# self-hosted engine, within ${decideBudgetMs('bash', opts.harness)} ms less what the deadline has left.
|
|
1533
|
+
# A CLI that is missing, fails, times out or answers what this gate cannot
|
|
1534
|
+
# read is unavailable, which refuses unless every record on the tool is
|
|
1535
|
+
# stamped shadow.
|
|
1536
|
+
_intutic_decide_match=0
|
|
1537
|
+
_intutic_decide_enforce=0
|
|
1538
|
+
if [ -z "\${1:-}" ] && [ \${#INTUTIC_DECIDE_RECORDS[@]} -gt 0 ]; then
|
|
1539
|
+
for _rec in "\${INTUTIC_DECIDE_RECORDS[@]}"; do
|
|
1540
|
+
IFS=$'\\t' read -r _ _d_fb _d_roll _d_tools _d_rest <<< "$_rec"
|
|
1541
|
+
case ",\${_d_tools}," in
|
|
1542
|
+
*",\${TOOL:-},"*)
|
|
1543
|
+
_intutic_decide_match=1
|
|
1544
|
+
[ "$_d_roll" = "enforce" ] && _intutic_decide_enforce=1
|
|
1545
|
+
;;
|
|
1546
|
+
esac
|
|
1547
|
+
done
|
|
1548
|
+
fi
|
|
1549
|
+
if [ "$_intutic_decide_match" = "1" ]; then
|
|
1550
|
+
_intutic_decide_budget=$(( ${gateDeadlineMs(opts.harness)} - (SECONDS + 1) * 1000 - ${DECIDE_BUDGET_MARGIN_MS} ))
|
|
1551
|
+
[ "$_intutic_decide_budget" -gt ${decideBudgetMs('bash', opts.harness)} ] && _intutic_decide_budget=${decideBudgetMs('bash', opts.harness)}
|
|
1552
|
+
if [ "$_intutic_decide_budget" -lt ${DECIDE_MIN_CALL_MS} ]; then
|
|
1553
|
+
if [ "$_intutic_decide_enforce" = "1" ]; then
|
|
1554
|
+
_intutic_dec="refuse"$'\\x1f'"GOVERNANCE_UNAVAILABLE"$'\\x1f'$'\\x1f'"GOVERNANCE_UNAVAILABLE — no time was left for the decide rules on \${TOOL:-} within the gate deadline"
|
|
1555
|
+
else
|
|
1556
|
+
_intutic_dec="allow"$'\\x1f'$'\\x1f'$'\\x1f'"no time was left for the decide rules on \${TOOL:-} within the gate deadline"
|
|
1557
|
+
fi
|
|
1558
|
+
else
|
|
1559
|
+
_intutic_dec="$(python3 -c 'import os, sys
|
|
1560
|
+
lib = {}
|
|
1561
|
+
exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
|
|
1562
|
+
sys.stdout.write(lib["intutic_decide_check"](*sys.argv[1:6], sys.stdin.read()))' ${shq(opts.harness)} "\${TOOL:-}" "$_intutic_decide_budget" "\${SESSION_ID:-}" "$_intutic_decide_enforce" <<< "\${TOOL_INPUT_JSON:-}" 2>/dev/null || true)"
|
|
1563
|
+
if [ -z "$_intutic_dec" ]; then
|
|
1564
|
+
# python3 could not say (it is required, and checked, before any rule
|
|
1565
|
+
# runs): unavailable, as a CLI that did not answer is.
|
|
1566
|
+
if [ "$_intutic_decide_enforce" = "1" ]; then
|
|
1567
|
+
_intutic_dec="refuse"$'\\x1f'"GOVERNANCE_UNAVAILABLE"$'\\x1f'$'\\x1f'"GOVERNANCE_UNAVAILABLE — the decide check could not run"
|
|
1568
|
+
else
|
|
1569
|
+
_intutic_dec="allow"$'\\x1f'$'\\x1f'$'\\x1f'"the decide check could not run"
|
|
1570
|
+
fi
|
|
1571
|
+
fi
|
|
1572
|
+
fi
|
|
1573
|
+
IFS=$'\\x1f' read -r _intutic_dec_out _intutic_dec_code _intutic_dec_rid _intutic_dec_reason <<< "$_intutic_dec"
|
|
1574
|
+
if [ "$_intutic_dec_out" = "allow" ]; then
|
|
1575
|
+
# A shadow rule's engine that could not answer: recorded, and the call goes on.
|
|
1576
|
+
if [ -n "$_intutic_dec_reason" ]; then
|
|
1577
|
+
${log} "tool_would_block" "\${TOOL:-}" "GOVERNANCE_UNAVAILABLE — $_intutic_dec_reason" || true
|
|
1578
|
+
fi
|
|
1579
|
+
else
|
|
1580
|
+
_intutic_dec_full="$_intutic_dec_reason"
|
|
1581
|
+
[ -n "$_intutic_dec_rid" ] && _intutic_dec_full="$_intutic_dec_full [$_intutic_dec_rid]"
|
|
1582
|
+
${askBlock} _intutic_chaos_rule="$_intutic_dec_rid"; _intutic_chaos_hit=1
|
|
1583
|
+
intutic_shown "$_intutic_dec_full"
|
|
1584
|
+
echo "[Intutic Governance] BLOCKED: \${_intutic_shown} (refusal code \${_intutic_dec_code:-DECIDE_ESCALATED})" >&2
|
|
1585
|
+
${log} "tool_blocked" "\${TOOL:-}" "$_intutic_dec_full"
|
|
1586
|
+
exit 2
|
|
1587
|
+
fi
|
|
1588
|
+
fi
|
|
1589
|
+
`;
|
|
1590
|
+
}
|
|
806
1591
|
export function emitShellGate(opts) {
|
|
807
1592
|
const log = opts.logFn ?? 'log_event';
|
|
808
1593
|
const floor = staticFloorPatterns();
|
|
1594
|
+
const observable = POST_TOOL_OBSERVABLE[opts.harness] ? '1' : '0';
|
|
1595
|
+
const ruleKinds = Object.keys(GATE_EVALUATES_RULE_RECORDS.bash).filter((k) => GATE_EVALUATES_RULE_RECORDS.bash[k]).join(',');
|
|
1596
|
+
const pyCall = (fn, args) => `python3 -c 'import os, sys\nlib = {}\nexec(os.environ.get("INTUTIC_PY_LIB", ""), lib)\nsys.stdout.write(lib["${fn}"](${args}))'`;
|
|
809
1597
|
return `
|
|
810
1598
|
# ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
|
|
811
1599
|
# Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
|
|
@@ -814,6 +1602,18 @@ export function emitShellGate(opts) {
|
|
|
814
1602
|
# were previously passed in and dropped on the floor: opts.harness was declared
|
|
815
1603
|
# on the option type, supplied by all thirteen writers, and read nowhere.
|
|
816
1604
|
|
|
1605
|
+
# ── Post-tool outcome (gate body v23) ────────────────────────────────────────
|
|
1606
|
+
# A post-tool invocation (--event post) records a test run's outcome for this
|
|
1607
|
+
# session, or marks an earlier passing run stale after a file change, and
|
|
1608
|
+
# always allows: the call has already run. Only where ${opts.harness}'s post-tool
|
|
1609
|
+
# payload was captured and carries an exit code (POST_TOOL_OBSERVABLE: ${observable}).
|
|
1610
|
+
if [ "\${INTUTIC_POST_MODE:-0}" = "1" ]; then
|
|
1611
|
+
if [ "${observable}" = "1" ]; then
|
|
1612
|
+
${pyCall('intutic_post_record', 'sys.argv[1], sys.stdin.read()')} ${shq(opts.harness)} <<< "\${INPUT:-}" >/dev/null 2>&1 || true
|
|
1613
|
+
fi
|
|
1614
|
+
exit 0
|
|
1615
|
+
fi
|
|
1616
|
+
|
|
817
1617
|
# ── Agent fault injection (intutic chaos run) ────────────────────────────────
|
|
818
1618
|
# Only when INTUTIC_CHAOS_PLAN is set: a chaos experiment's runner sets it, with
|
|
819
1619
|
# INTUTIC_CHAOS_EVENTS, in the sandbox the agent runs in, and this gate inherits
|
|
@@ -932,6 +1732,14 @@ INTUTIC_MCP_REGISTRY=""
|
|
|
932
1732
|
# text, never allows a call.
|
|
933
1733
|
INTUTIC_OPAQUE=0
|
|
934
1734
|
INTUTIC_OPAQUE_MESSAGE=${shq(OPAQUE_REFUSAL_MESSAGE)}
|
|
1735
|
+
# The snapshot format (the @format record; 1 without one), and the decide
|
|
1736
|
+
# rules' @decide records, kept whole for the decide tier: this gate decodes
|
|
1737
|
+
# none of them, the CLI does.
|
|
1738
|
+
INTUTIC_SNAPSHOT_FORMAT=1
|
|
1739
|
+
INTUTIC_DECIDE_RECORDS=()
|
|
1740
|
+
# The @rule records (gate body v23), kept whole: the extractor's Python decodes
|
|
1741
|
+
# them for the rule-record tiers below.
|
|
1742
|
+
INTUTIC_RULE_RECORDS=()
|
|
935
1743
|
if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
|
|
936
1744
|
INTUTIC_SNAPSHOT_STATE="ok"
|
|
937
1745
|
while IFS= read -r _line || [ -n "$_line" ]; do
|
|
@@ -950,12 +1758,17 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
|
|
|
950
1758
|
;;
|
|
951
1759
|
'${MCP_REGISTRY_RECORD_TAG}'$'\\t'*) INTUTIC_MCP_REGISTRY="\${_line#*$'\\t'}" ;;
|
|
952
1760
|
'${INTERVENTION_MODE_RECORD_TAG}'$'\\t''OPAQUE') INTUTIC_OPAQUE=1 ;;
|
|
1761
|
+
'${SNAPSHOT_FORMAT_RECORD_TAG}'$'\\t'*)
|
|
1762
|
+
case "\${_line#*$'\\t'}" in ''|*[!0-9]*) : ;; *) INTUTIC_SNAPSHOT_FORMAT="\${_line#*$'\\t'}" ;; esac
|
|
1763
|
+
;;
|
|
1764
|
+
'${DECIDE_RECORD_TAG}'$'\\t'*) INTUTIC_DECIDE_RECORDS+=("$_line") ;;
|
|
1765
|
+
'${RULE_RECORD_TAG}'$'\\t'*) INTUTIC_RULE_RECORDS+=("$_line") ;;
|
|
953
1766
|
'#'*|'') : ;;
|
|
954
1767
|
*) INTUTIC_DYNAMIC+=("$_line") ;;
|
|
955
1768
|
esac
|
|
956
1769
|
done < "$INTUTIC_SNAPSHOT_RULES"
|
|
957
1770
|
|
|
958
|
-
# Integrity, in the order that fails cheapest first.
|
|
1771
|
+
${shellDecideFallbackSkip(GATE_EVALUATES_DECIDE.bash)}${shellRuleFallbackSkip(ruleKinds)}${SHELL_SHADOWED_PHRASE_SKIP} # Integrity, in the order that fails cheapest first.
|
|
959
1772
|
#
|
|
960
1773
|
# All three of these were parsed and then ignored. A digest nobody recomputes
|
|
961
1774
|
# is not integrity, it is a comment; and a workspace id nobody compares means a
|
|
@@ -1001,6 +1814,9 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
|
|
|
1001
1814
|
# never set: the snapshot admits no MCP server (the refusal below).
|
|
1002
1815
|
INTUTIC_MCP_REGISTRY=""
|
|
1003
1816
|
INTUTIC_MCP_ALLOWLIST=0
|
|
1817
|
+
# The decide and rule records go with the rest of the dynamic tier.
|
|
1818
|
+
INTUTIC_DECIDE_RECORDS=()
|
|
1819
|
+
INTUTIC_RULE_RECORDS=()
|
|
1004
1820
|
fi
|
|
1005
1821
|
fi
|
|
1006
1822
|
|
|
@@ -1025,6 +1841,9 @@ case "$INTUTIC_SNAPSHOT_STATE" in
|
|
|
1025
1841
|
empty) ${log} "snapshot_empty" "\${TOOL:-}" "Policy snapshot contains no rules — the compile produced nothing" || true ;;
|
|
1026
1842
|
stale) ${log} "snapshot_stale" "\${TOOL:-}" "Policy snapshot is \${_intutic_age_days} days old and still enforced" || true ;;
|
|
1027
1843
|
esac
|
|
1844
|
+
if [ "$INTUTIC_SNAPSHOT_FORMAT" -gt ${GATE_READS_FORMAT} ]; then
|
|
1845
|
+
${log} "snapshot_format_newer" "\${TOOL:-}" "${FORMAT_NEWER_MESSAGE.replace('{n}', '$INTUTIC_SNAPSHOT_FORMAT')}" || true
|
|
1846
|
+
fi
|
|
1028
1847
|
|
|
1029
1848
|
# Normalise once; every pattern is written against this shape. The extractor
|
|
1030
1849
|
# hands each field over with its whitespace already collapsed to single spaces
|
|
@@ -1246,6 +2065,13 @@ intutic_apply() {
|
|
|
1246
2065
|
destructive.*) return 0 ;;
|
|
1247
2066
|
esac
|
|
1248
2067
|
fi
|
|
2068
|
+
# The tier order (gate body v23): every refusal first, the holds after the
|
|
2069
|
+
# rule records and the MCP checks. A hold is only ever decided on a call
|
|
2070
|
+
# nothing deterministic refused.
|
|
2071
|
+
case "\${_intutic_pass:-all}" in
|
|
2072
|
+
det) [ "$rsev" = "hold" ] && return 0 ;;
|
|
2073
|
+
hold) [ "$rsev" = "hold" ] || return 0 ;;
|
|
2074
|
+
esac
|
|
1249
2075
|
intutic_rule_fires "$rec"
|
|
1250
2076
|
[ "$_intutic_fires" = "1" ] || return 0
|
|
1251
2077
|
if [ "$rsev" = "shadow" ]; then
|
|
@@ -1350,12 +2176,51 @@ for _rec in "\${INTUTIC_FLOOR[@]}"; do
|
|
|
1350
2176
|
intutic_apply "$_rec"
|
|
1351
2177
|
done
|
|
1352
2178
|
|
|
2179
|
+
_intutic_pass=det
|
|
1353
2180
|
if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
|
|
1354
2181
|
for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
|
|
1355
2182
|
intutic_apply "$_rec"
|
|
1356
2183
|
done
|
|
1357
2184
|
fi
|
|
1358
2185
|
|
|
2186
|
+
# ── Rule records (gate body v23) ─────────────────────────────────────────────
|
|
2187
|
+
# A rule record's verdict, as the Python check returns it: verdict, code, rule
|
|
2188
|
+
# id and reason, tab-separated; empty to continue. shadow records the finding;
|
|
2189
|
+
# block refuses.
|
|
2190
|
+
intutic_rule_verdict() {
|
|
2191
|
+
[ -n "$1" ] || return 0
|
|
2192
|
+
local verdict code rid reason retry
|
|
2193
|
+
IFS=$'\t' read -r verdict code rid reason retry <<< "$1"
|
|
2194
|
+
if [ "$verdict" = "shadow" ]; then
|
|
2195
|
+
intutic_chaos_note "$rid"
|
|
2196
|
+
${log} "tool_would_block" "\${TOOL:-}" "\${reason} [\${rid}]" || true
|
|
2197
|
+
return 0
|
|
2198
|
+
fi
|
|
2199
|
+
_intutic_chaos_rule="$rid"; _intutic_chaos_hit=1
|
|
2200
|
+
intutic_shown "\${reason} [\${rid}]"
|
|
2201
|
+
echo "[Intutic Governance] BLOCKED: \${_intutic_shown} (refusal code \${code})" >&2
|
|
2202
|
+
# Where to retry: the paths or tools the refusal names. An
|
|
2203
|
+
# OPAQUE workspace drops it, as it drops the rule.
|
|
2204
|
+
if [ -n "$retry" ] && [ "$INTUTIC_OPAQUE" != "1" ]; then echo "$retry" >&2; fi
|
|
2205
|
+
${log} "tool_blocked" "\${TOOL:-}" "\${reason} [\${rid}]"
|
|
2206
|
+
exit 2
|
|
2207
|
+
}
|
|
2208
|
+
_intutic_rr_text=""
|
|
2209
|
+
if [ \${#INTUTIC_RULE_RECORDS[@]} -gt 0 ]; then
|
|
2210
|
+
_intutic_rr_text="$(printf '%s\n' "\${INTUTIC_RULE_RECORDS[@]}")"
|
|
2211
|
+
fi
|
|
2212
|
+
# requires_outcome: a call whose action token (or tool) a qualified
|
|
2213
|
+
# requires_before gates is refused unless this session's last test run passed,
|
|
2214
|
+
# and no file changed since (PREREQUISITE_UNMET). The check also keeps the
|
|
2215
|
+
# session's state: a file change marks a passing run stale. A check that
|
|
2216
|
+
# cannot run refuses.
|
|
2217
|
+
case "$_intutic_rr_text" in
|
|
2218
|
+
*$'\t'requires_outcome$'\t'*)
|
|
2219
|
+
_intutic_rr="$(${pyCall('intutic_outcome_check', '*(sys.argv[1:7] + [sys.stdin.read()])')} ${shq(opts.harness)} ${observable} "\${TOOL:-}" "\${INTUTIC_ACTIONS:- }" "\${SESSION_ID:-}" "$_intutic_rr_text" <<< "\${TOOL_INPUT_JSON:-}" 2>/dev/null || printf 'block\tPREREQUISITE_UNMET\trequires_before\tthe requires_before check could not run')"
|
|
2220
|
+
intutic_rule_verdict "$_intutic_rr"
|
|
2221
|
+
;;
|
|
2222
|
+
esac
|
|
2223
|
+
|
|
1359
2224
|
# ── Unverified snapshot (gate body v17) ──────────────────────────────────────
|
|
1360
2225
|
# A snapshot that failed its digest or workspace check admits no MCP server:
|
|
1361
2226
|
# neither record in it can be vouched for, and a deleted one looks like one
|
|
@@ -1445,6 +2310,27 @@ case "\${TOOL:-}" in
|
|
|
1445
2310
|
;;
|
|
1446
2311
|
esac
|
|
1447
2312
|
|
|
2313
|
+
# ── Holds (gate body v8; after every refusal since v23) ──────────────────────
|
|
2314
|
+
_intutic_pass=hold
|
|
2315
|
+
if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
|
|
2316
|
+
for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
|
|
2317
|
+
intutic_apply "$_rec"
|
|
2318
|
+
done
|
|
2319
|
+
fi
|
|
2320
|
+
|
|
2321
|
+
# ── Trajectory: scope_paths and plan_approval (gate body v23) ────────────────
|
|
2322
|
+
# A file change outside the union of scope_paths (OUT_OF_SCOPE), and a call
|
|
2323
|
+
# before the session's plan is approved (PLAN_NOT_APPROVED) or, under strict,
|
|
2324
|
+
# off its steps (OFF_PLAN). After the holds, before the decide tier. A check
|
|
2325
|
+
# that cannot run refuses.
|
|
2326
|
+
case "$_intutic_rr_text" in
|
|
2327
|
+
*$'\t'scope_paths$'\t'*|*$'\t'plan_approval$'\t'*)
|
|
2328
|
+
_intutic_tr="$(${pyCall('intutic_trajectory_check', '*(sys.argv[1:4] + [sys.argv[4], sys.stdin.read(), sys.argv[5], sys.argv[6]])')} ${shq(opts.harness)} "\${TOOL:-}" "\${SESSION_ID:-}" "$_intutic_rr_text" "$PWD" "\${INTUTIC_WORKSPACE_ID:-}" <<< "\${TOOL_INPUT_JSON:-}" 2>/dev/null || printf 'block\tOUT_OF_SCOPE\tscope_paths\tthe scope and plan check could not run')"
|
|
2329
|
+
intutic_rule_verdict "$_intutic_tr"
|
|
2330
|
+
;;
|
|
2331
|
+
esac
|
|
2332
|
+
|
|
2333
|
+
${shellDecideTier(opts)}
|
|
1448
2334
|
# ── Agent fault injection: the call is allowed ───────────────────────────────
|
|
1449
2335
|
# Recorded as allowed, then the fault that is due, if any. A permission delay
|
|
1450
2336
|
# never waits here: the call is held, and its identical retry (the same key an
|
|
@@ -1468,16 +2354,130 @@ fi
|
|
|
1468
2354
|
# ── end Intutic gate body ────────────────────────────────────────────────────
|
|
1469
2355
|
`;
|
|
1470
2356
|
}
|
|
2357
|
+
/**
|
|
2358
|
+
* The decide modules every JS-family gate embeds beside
|
|
2359
|
+
* {@link jsSnapshotLoader}: the snapshot's decoders and, for the decide
|
|
2360
|
+
* tier, the engine client and the protections around it (`DECIDE_GATE_JS_SOURCE`
|
|
2361
|
+
* in `@intutic/shared-types`), each module in a scope of its own.
|
|
2362
|
+
*/
|
|
2363
|
+
const JS_DECIDE_LIB = `// Decide rules, emitted from decide.ts, decideRecord.ts, decideHostGuard.ts,
|
|
2364
|
+
// decideEngine.ts and decideEvaluate.ts in @intutic/shared-types: the @format
|
|
2365
|
+
// record, each @decide record checked against the rule it carries, and the
|
|
2366
|
+
// engine client and protections the decide tier runs.
|
|
2367
|
+
const intuticDecideLib = (${DECIDE_GATE_JS_SOURCE})(function (text) {
|
|
2368
|
+
return require('crypto').createHash('sha256').update(text, 'utf8').digest('hex');
|
|
2369
|
+
});`;
|
|
2370
|
+
/** A JS gate that evaluates decide rules skips each fallback line a decoded record names. */
|
|
2371
|
+
const JS_DECIDE_FALLBACK_SKIP = ` // A fallback line stands in for a decide rule a gate cannot evaluate. This
|
|
2372
|
+
// gate reads the rule from its record, so it skips the line, but only when
|
|
2373
|
+
// that record decoded: any other line with the prefix is enforced.
|
|
2374
|
+
if (out.decideRules.length > 0) {
|
|
2375
|
+
const skip = out.decideRules.map(function (r) { return r.fallbackId; });
|
|
2376
|
+
out.rules = out.rules.filter(function (r) {
|
|
2377
|
+
return r.id.indexOf('${DECIDE_FALLBACK_PREFIX}') !== 0 || skip.indexOf(r.id) === -1;
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
`;
|
|
2381
|
+
/** A JS gate that does not evaluate decide rules keeps every fallback line. */
|
|
2382
|
+
const JS_DECIDE_FALLBACK_KEPT = ` // A fallback line stands in for a decide rule a gate cannot evaluate. This
|
|
2383
|
+
// gate evaluates none, so every fallback line stays in force and refuses
|
|
2384
|
+
// its rule's tools, as in a gate that reads no @decide records.
|
|
2385
|
+
`;
|
|
2386
|
+
/**
|
|
2387
|
+
* A hold token's rules share one id (`laneHoldPatterns`, `@intutic/shared-types`):
|
|
2388
|
+
* a `phrase` rule, which a gate whose classifier predates the token holds
|
|
2389
|
+
* by, and for force_push and destroy an `action` rule. A v23 gate classifies
|
|
2390
|
+
* every token, so it skips a `phrase` rule that shares its id with an
|
|
2391
|
+
* `action` rule and holds by the action rule alone: the partial phrase
|
|
2392
|
+
* `git push --force` also matches `--force-with-lease`, which is no force
|
|
2393
|
+
* push. One id means one approval releases the call at every gate, old or
|
|
2394
|
+
* new.
|
|
2395
|
+
*/
|
|
2396
|
+
const JS_SHADOWED_PHRASE_SKIP = ` // A phrase rule that shares its id with an action rule is the partial form a
|
|
2397
|
+
// gate older than v23 holds by; this one holds by the action rule.
|
|
2398
|
+
{
|
|
2399
|
+
const actionIds = out.rules.filter(function (r) { return r.subject === 'action'; }).map(function (r) { return r.id; });
|
|
2400
|
+
if (actionIds.length > 0) out.rules = out.rules.filter(function (r) { return r.subject !== 'phrase' || actionIds.indexOf(r.id) === -1; });
|
|
2401
|
+
}
|
|
2402
|
+
`;
|
|
2403
|
+
/** {@link JS_SHADOWED_PHRASE_SKIP} in a bash gate. Fields are cut by expansion: an empty flags column must not shift the subject. */
|
|
2404
|
+
const SHELL_SHADOWED_PHRASE_SKIP = ` # A phrase rule that shares its id with an action rule is the partial form a
|
|
2405
|
+
# gate older than v23 holds by; this one holds by the action rule.
|
|
2406
|
+
if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
|
|
2407
|
+
_intutic_aids=$'\\n'
|
|
2408
|
+
for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
|
|
2409
|
+
_r="\${_rec#*$'\\t'}"; _r="\${_r#*$'\\t'}"; _r="\${_r#*$'\\t'}"
|
|
2410
|
+
if [ "\${_r%%$'\\t'*}" = "action" ]; then _intutic_aids+="\${_rec%%$'\\t'*}"$'\\n'; fi
|
|
2411
|
+
done
|
|
2412
|
+
if [ "$_intutic_aids" != $'\\n' ]; then
|
|
2413
|
+
_intutic_kept=()
|
|
2414
|
+
for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
|
|
2415
|
+
_r="\${_rec#*$'\\t'}"; _r="\${_r#*$'\\t'}"; _r="\${_r#*$'\\t'}"
|
|
2416
|
+
if [ "\${_r%%$'\\t'*}" = "phrase" ]; then
|
|
2417
|
+
case "$_intutic_aids" in *$'\\n'"\${_rec%%$'\\t'*}"$'\\n'*) continue ;; esac
|
|
2418
|
+
fi
|
|
2419
|
+
_intutic_kept+=("$_rec")
|
|
2420
|
+
done
|
|
2421
|
+
INTUTIC_DYNAMIC=()
|
|
2422
|
+
if [ \${#_intutic_kept[@]} -gt 0 ]; then INTUTIC_DYNAMIC=("\${_intutic_kept[@]}"); fi
|
|
2423
|
+
fi
|
|
2424
|
+
fi
|
|
2425
|
+
|
|
2426
|
+
`;
|
|
2427
|
+
/** A JS gate that evaluates rule records skips each fallback line a decoded, enforcing record of those kinds names. */
|
|
2428
|
+
function jsRuleFallbackSkip(kinds) {
|
|
2429
|
+
return ` // A <kind>-fallback: line stands in for a rule record a gate cannot
|
|
2430
|
+
// evaluate. This gate evaluates ${kinds.join(', ')}, so it skips the line a
|
|
2431
|
+
// decoded record of those kinds names; any other fallback line is enforced.
|
|
2432
|
+
if (out.gateRecords.length > 0) {
|
|
2433
|
+
const kinds = ${JSON.stringify(kinds)};
|
|
2434
|
+
const rskip = out.gateRecords.filter(function (r) { return r.fallbackId && kinds.indexOf(r.kind) !== -1; })
|
|
2435
|
+
.map(function (r) { return r.fallbackId; });
|
|
2436
|
+
out.rules = out.rules.filter(function (r) { return rskip.indexOf(r.id) === -1; });
|
|
2437
|
+
}
|
|
2438
|
+
`;
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* The shared rule-record ports from `@intutic/shared-types`, in a scope of
|
|
2442
|
+
* their own: the test outcome decision and its state
|
|
2443
|
+
* ({@link TEST_OUTCOME_JS_SOURCE}), the scope match
|
|
2444
|
+
* ({@link SCOPE_MATCH_JS_SOURCE}), the `@rule` decoder
|
|
2445
|
+
* ({@link RULE_RECORD_JS_SOURCE}) and the refusal detail
|
|
2446
|
+
* ({@link REFUSAL_DETAIL_JS_SOURCE}). The scope match calls the phrase
|
|
2447
|
+
* matcher (`PHRASES_JS_SOURCE`), which the classifier emits at the top level
|
|
2448
|
+
* of every JS gate; it is read at call time, after both are defined.
|
|
2449
|
+
*/
|
|
2450
|
+
const JS_RULE_LIB = `// Rule records, test outcomes, scope matching and refusal detail, emitted
|
|
2451
|
+
// from @intutic/shared-types (testOutcome.ts, scopeMatch.ts, gateRuleRecord.ts,
|
|
2452
|
+
// refusalDetail.ts).
|
|
2453
|
+
const intuticRuleLib = (function () {
|
|
2454
|
+
${TEST_OUTCOME_JS_SOURCE}
|
|
2455
|
+
${SCOPE_MATCH_JS_SOURCE}
|
|
2456
|
+
${RULE_RECORD_JS_SOURCE}
|
|
2457
|
+
${REFUSAL_DETAIL_JS_SOURCE}
|
|
2458
|
+
return { decode: decodeGateRuleRecord, exitCode: extractExitCode, outcome: decideTestOutcome,
|
|
2459
|
+
emptyState: emptyOutcomeState, parseState: parseOutcomeState, recordOutcome: recordTestOutcome,
|
|
2460
|
+
noteCall: noteCompletedCall, prerequisite: prerequisiteCase, unmetReason: prerequisiteUnmetReason,
|
|
2461
|
+
withinScope: isWithinScope, changeTargets: changeTargets, isFileWrite: isFileWriteCall,
|
|
2462
|
+
planExempt: isPlanExemptTool, detail: refusalDetailForMode, retryLine: retryScopeLine };
|
|
2463
|
+
})();`;
|
|
1471
2464
|
/**
|
|
1472
2465
|
* The snapshot loader every JS-family gate embeds, defined once.
|
|
1473
2466
|
*
|
|
1474
2467
|
* Extracted from {@link emitJsGate}'s template when the n8n workflow gate
|
|
1475
2468
|
* arrived: two emitters needing the same loader is exactly the situation that
|
|
1476
|
-
* produced the four defects this module's header describes, so the loader is
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
2469
|
+
* produced the four defects this module's header describes, so the loader is
|
|
2470
|
+
* one function both interpolate rather than a block one of them copies. It
|
|
2471
|
+
* needs {@link JS_DECIDE_LIB} in scope.
|
|
2472
|
+
*
|
|
2473
|
+
* Its decide state: `format` (1 without an @format record), and
|
|
2474
|
+
* `decideRules`, the records that decoded. Only a gate that evaluates decide
|
|
2475
|
+
* rules (`evaluatesDecide`, {@link GATE_EVALUATES_DECIDE}) drops a fallback
|
|
2476
|
+
* line, and only when a decoded record names it; any other gate keeps every
|
|
2477
|
+
* fallback line in force.
|
|
1479
2478
|
*/
|
|
1480
|
-
|
|
2479
|
+
function jsSnapshotLoader(evaluatesDecide, ruleKinds = [], skipShadowedPhrases = false) {
|
|
2480
|
+
return `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
1481
2481
|
const fs = require('fs'), os = require('os'), path = require('path');
|
|
1482
2482
|
const p = process.env.INTUTIC_SNAPSHOT_RULES ||
|
|
1483
2483
|
path.join(os.homedir(), '.intutic', 'hooks', 'policy-snapshot.rules');
|
|
@@ -1486,7 +2486,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1486
2486
|
// opaque: an OPAQUE workspace (the @intervention_mode record). Kept on a
|
|
1487
2487
|
// snapshot that fails its digest: it only ever hides text, never allows.
|
|
1488
2488
|
const out = { rules: [], digest: 'none', state: 'absent', workspaceId: '', generatedAt: '', ageDays: 0,
|
|
1489
|
-
mcpAllowlist: null, mcpRegistry: null, opaque: false };
|
|
2489
|
+
mcpAllowlist: null, mcpRegistry: null, opaque: false, format: 1, decideRules: [], gateRecords: [] };
|
|
1490
2490
|
let text;
|
|
1491
2491
|
try { text = fs.readFileSync(p, 'utf8'); } catch (e) { return out; }
|
|
1492
2492
|
out.state = 'ok';
|
|
@@ -1495,6 +2495,28 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1495
2495
|
if (line.startsWith('#workspace ')) { out.workspaceId = line.slice(11).trim(); continue; }
|
|
1496
2496
|
if (line.startsWith('#generated ')) { out.generatedAt = line.slice(11).trim(); continue; }
|
|
1497
2497
|
if (line === '${INTERVENTION_MODE_RECORD_TAG}\\tOPAQUE') { out.opaque = true; continue; }
|
|
2498
|
+
if (line.startsWith('${SNAPSHOT_FORMAT_RECORD_TAG}\\t')) {
|
|
2499
|
+
const v = intuticDecideLib.format(line);
|
|
2500
|
+
if (v !== null) out.format = v;
|
|
2501
|
+
continue;
|
|
2502
|
+
}
|
|
2503
|
+
if (line.startsWith('${DECIDE_RECORD_TAG}\\t')) {
|
|
2504
|
+
// A decide rule's record. One that does not decode is skipped, and the
|
|
2505
|
+
// rule's fallback line below then stays in force and refuses.
|
|
2506
|
+
const rec = intuticDecideLib.record(line);
|
|
2507
|
+
if (rec) out.decideRules.push(rec);
|
|
2508
|
+
continue;
|
|
2509
|
+
}
|
|
2510
|
+
if (line.startsWith('${RULE_RECORD_TAG}\\t')) {
|
|
2511
|
+
${ruleKinds.length > 0
|
|
2512
|
+
? ` // A rule record (gate body v23). One that does not decode is skipped, and
|
|
2513
|
+
// its fallback line below stays in force and refuses.
|
|
2514
|
+
const rr = intuticRuleLib.decode(line);
|
|
2515
|
+
if (rr) out.gateRecords.push(rr);`
|
|
2516
|
+
: ` // A rule record (gate body v23). This gate evaluates no kind, so each
|
|
2517
|
+
// record's fallback line stays in force and refuses.`}
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
1498
2520
|
if (line.startsWith('${MCP_ALLOWLIST_RECORD_TAG}\\t')) {
|
|
1499
2521
|
// The MCP server allowlist: tag, severity and comma-joined servers, a
|
|
1500
2522
|
// data line inside the digest. Any severity but shadow refuses.
|
|
@@ -1524,6 +2546,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1524
2546
|
if (!line || line.startsWith('#')) continue;
|
|
1525
2547
|
const f = line.split('\\t');
|
|
1526
2548
|
if (f.length < 6 || !f[5]) continue;
|
|
2549
|
+
|
|
1527
2550
|
let re, seq = null;
|
|
1528
2551
|
// A snapshot rule that will not compile is dropped, not fatal. The floor
|
|
1529
2552
|
// above is compiled in and unaffected, so this degrades to "today's
|
|
@@ -1545,10 +2568,13 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1545
2568
|
try { argRe = new RegExp(Buffer.from(f[6], 'base64').toString('utf8')); }
|
|
1546
2569
|
catch (e) { argDowngraded = true; }
|
|
1547
2570
|
}
|
|
1548
|
-
|
|
2571
|
+
// src: the source as written. A phrase rule reads it, never re.source,
|
|
2572
|
+
// which escapes a slash ('api.stripe.com/v1/charges' read back as
|
|
2573
|
+
// 'api.stripe.com\\/v1\\/charges' matched nothing).
|
|
2574
|
+
out.rules.push({ id: f[0], severity: f[1], subject: f[3] || 'any', re: re, seq: seq, reason: f[4], src: f[5],
|
|
1549
2575
|
argRe: argRe, argDowngraded: argDowngraded });
|
|
1550
2576
|
}
|
|
1551
|
-
|
|
2577
|
+
${evaluatesDecide ? JS_DECIDE_FALLBACK_SKIP : JS_DECIDE_FALLBACK_KEPT}${ruleKinds.length > 0 ? jsRuleFallbackSkip(ruleKinds) : ''}${skipShadowedPhrases ? JS_SHADOWED_PHRASE_SKIP : ''}
|
|
1552
2578
|
// Integrity. A digest nobody recomputes is a comment, and a workspace id
|
|
1553
2579
|
// nobody compares means a snapshot from workspace A dropped onto workspace B's
|
|
1554
2580
|
// machine is enforced verbatim, with B's events attributing A's rules to B.
|
|
@@ -1578,6 +2604,9 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1578
2604
|
// never set: the gate admits no MCP server on this snapshot at all.
|
|
1579
2605
|
out.mcpRegistry = null;
|
|
1580
2606
|
out.mcpAllowlist = null;
|
|
2607
|
+
// The decide and rule records go with the rest of the dynamic tier.
|
|
2608
|
+
out.decideRules = [];
|
|
2609
|
+
out.gateRecords = [];
|
|
1581
2610
|
}
|
|
1582
2611
|
|
|
1583
2612
|
if (out.state === 'ok' && out.generatedAt) {
|
|
@@ -1590,6 +2619,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1590
2619
|
}
|
|
1591
2620
|
return out;
|
|
1592
2621
|
}`;
|
|
2622
|
+
}
|
|
1593
2623
|
/**
|
|
1594
2624
|
* A chaos run's side of the JS gates, defined once: the plan, this call's
|
|
1595
2625
|
* record, and the fault to apply to an allowed call.
|
|
@@ -1676,6 +2706,244 @@ function intuticChaosKey(toolName, command, target) {
|
|
|
1676
2706
|
.update(intuticNormalise(toolName) + '\\u0000' + intuticNormalise(command) + '\\u0000' + intuticNormalise(target))
|
|
1677
2707
|
.digest('hex');
|
|
1678
2708
|
}`;
|
|
2709
|
+
/**
|
|
2710
|
+
* The rule-record tiers of a JS gate, emitted once per gate: the
|
|
2711
|
+
* `requires_outcome` check and its state, the trajectory check
|
|
2712
|
+
* (`scope_paths`, `plan_approval`) and the post-tool recorder
|
|
2713
|
+
* (`intuticRecordOutcome`). Needs {@link JS_RULE_LIB} and the classifier in
|
|
2714
|
+
* scope.
|
|
2715
|
+
*/
|
|
2716
|
+
function jsRuleTiers(harness) {
|
|
2717
|
+
return `// ── Rule records (gate body v23) ─────────────────────────────────────────────
|
|
2718
|
+
// The @rule records' tiers: requires_outcome among the deterministic refusals,
|
|
2719
|
+
// scope_paths and plan_approval after the holds (the trajectory tier), and
|
|
2720
|
+
// the post-tool recorder a writer calls with a tool's result. A record
|
|
2721
|
+
// stamped shadow records its finding and refuses nothing.
|
|
2722
|
+
const INTUTIC_HARNESS = ${JSON.stringify(harness)};
|
|
2723
|
+
// Whether this harness's post-tool payload was captured and carries an exit
|
|
2724
|
+
// code or an error flag (POST_TOOL_OBSERVABLE in gateBody.ts).
|
|
2725
|
+
const INTUTIC_POST_OBSERVABLE = ${POST_TOOL_OBSERVABLE[harness] ? 'true' : 'false'};
|
|
2726
|
+
// The working directory of the call, when the writer knows it (cwd in the
|
|
2727
|
+
// payload); the process's otherwise.
|
|
2728
|
+
var _intuticCallCwd = null;
|
|
2729
|
+
|
|
2730
|
+
/** ~/.intutic/hooks/state/outcomes/<sha256(harness NUL session)[:32]>.json */
|
|
2731
|
+
function intuticOutcomeStatePath(sessionId) {
|
|
2732
|
+
var path = require('path'), os = require('os');
|
|
2733
|
+
var base = process.env.INTUTIC_OUTCOME_STATE_DIR || path.join(os.homedir(), '.intutic', 'hooks', 'state', 'outcomes');
|
|
2734
|
+
var h = require('crypto').createHash('sha256').update(INTUTIC_HARNESS + '\\u0000' + String(sessionId), 'utf8').digest('hex').slice(0, 32);
|
|
2735
|
+
return path.join(base, h + '.json');
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
/** { exists, state }: a missing file is an empty state; one that does not read is null, which leaves every rule unmet. */
|
|
2739
|
+
function intuticOutcomeStateRead(file) {
|
|
2740
|
+
var fs = require('fs');
|
|
2741
|
+
if (!fs.existsSync(file)) return { exists: false, state: intuticRuleLib.emptyState() };
|
|
2742
|
+
try { return { exists: true, state: intuticRuleLib.parseState(fs.readFileSync(file, 'utf8')) }; }
|
|
2743
|
+
catch (e) { return { exists: true, state: null }; }
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
/** Written atomically; a write that fails changes no decision. */
|
|
2747
|
+
function intuticOutcomeStateWrite(file, state) {
|
|
2748
|
+
try {
|
|
2749
|
+
var fs = require('fs'), path = require('path');
|
|
2750
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2751
|
+
var tmp = file + '.tmp.' + process.pid;
|
|
2752
|
+
fs.writeFileSync(tmp, JSON.stringify(state));
|
|
2753
|
+
fs.renameSync(tmp, file);
|
|
2754
|
+
} catch (e) {}
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** Why a qualified prerequisite is not met (the shared reason), or null when it is. */
|
|
2758
|
+
function intuticOutcomeUnmet(r, sessionId, state) {
|
|
2759
|
+
var c, why = null;
|
|
2760
|
+
if (!INTUTIC_POST_OBSERVABLE) c = 'not_observable';
|
|
2761
|
+
else if (!sessionId) c = 'no_session';
|
|
2762
|
+
else { var got = intuticRuleLib.prerequisite(state, r.first, r.adjacent, Date.now()); c = got.case; why = got.why; }
|
|
2763
|
+
return c === 'met' ? null : intuticRuleLib.unmetReason(r.first, r.then, c, why, INTUTIC_HARNESS);
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
/** A rule record's finding; scope is where the refusal applies (§6.2). */
|
|
2767
|
+
function intuticRuleVerdict(r, code, ruleId, reason, scope) {
|
|
2768
|
+
return { verdict: r.rollout === 'enforce' ? 'block' : 'shadow', code: code, ruleId: ruleId, reason: reason, scope: scope };
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
/**
|
|
2772
|
+
* The requires_outcome tier: a call whose action token (or tool name) a
|
|
2773
|
+
* qualified requires_before gates, unless this session's last test run
|
|
2774
|
+
* passed and nothing changed since. Then the session's bookkeeping: a call
|
|
2775
|
+
* that changes files (a mutation tool, or a shell write such as sed -i or
|
|
2776
|
+
* git checkout: isFileWriteCall, Q1) marks a passing run stale, and every
|
|
2777
|
+
* call ends adjacency for ~> rules.
|
|
2778
|
+
*/
|
|
2779
|
+
function intuticOutcomeTier(snap, toolName, nActions, toolInput, sessionId) {
|
|
2780
|
+
var recs = snap.gateRecords.filter(function (r) { return r.kind === 'requires_outcome'; });
|
|
2781
|
+
var file = sessionId ? intuticOutcomeStatePath(sessionId) : null;
|
|
2782
|
+
var read = file ? intuticOutcomeStateRead(file) : { exists: false, state: intuticRuleLib.emptyState() };
|
|
2783
|
+
var shadow = null;
|
|
2784
|
+
for (var i = 0; i < recs.length; i++) {
|
|
2785
|
+
var r = recs[i];
|
|
2786
|
+
if (nActions.indexOf(' ' + r.then + ' ') === -1 && String(r.then).toLowerCase() !== String(toolName).toLowerCase()) continue;
|
|
2787
|
+
var why = intuticOutcomeUnmet(r, sessionId, read.state);
|
|
2788
|
+
if (why === null) continue;
|
|
2789
|
+
var v = intuticRuleVerdict(r, 'PREREQUISITE_UNMET', 'requires_before.' + r.first + '[ok]' + (r.adjacent ? '~>' : '->') + r.then,
|
|
2790
|
+
why, { tools: [r.then], step: r.first });
|
|
2791
|
+
if (v.verdict === 'block') return v;
|
|
2792
|
+
if (!shadow) shadow = v;
|
|
2793
|
+
}
|
|
2794
|
+
if (file && read.exists && read.state) {
|
|
2795
|
+
intuticOutcomeStateWrite(file, intuticRuleLib.noteCall(read.state, intuticRuleLib.isFileWrite(toolName, toolInput)));
|
|
2796
|
+
}
|
|
2797
|
+
return shadow;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/**
|
|
2801
|
+
* Records a tool's result, from the writer's post-tool handler: a shell call
|
|
2802
|
+
* that ran tests records its outcome for the session; a call that changed
|
|
2803
|
+
* files marks an earlier passing run stale. result: { exitCode?, isError?,
|
|
2804
|
+
* output? }. Never throws and never refuses.
|
|
2805
|
+
*/
|
|
2806
|
+
function intuticRecordOutcome(sessionId, toolName, toolInput, result) {
|
|
2807
|
+
if (!INTUTIC_POST_OBSERVABLE || !sessionId) return;
|
|
2808
|
+
try {
|
|
2809
|
+
var input = toolInput && typeof toolInput === 'object' ? toolInput : {};
|
|
2810
|
+
var command = String(input.command || input.cmd || input.script || input.shell_command || '');
|
|
2811
|
+
var actions = intuticActions(toolName, command);
|
|
2812
|
+
var file = intuticOutcomeStatePath(sessionId);
|
|
2813
|
+
var read = intuticOutcomeStateRead(file);
|
|
2814
|
+
if (actions.indexOf(' action:run_tests ') !== -1) {
|
|
2815
|
+
var output = String(result && result.output != null ? result.output : '');
|
|
2816
|
+
var exitCode = result && typeof result.exitCode === 'number' ? result.exitCode : intuticRuleLib.exitCode(output);
|
|
2817
|
+
var d = intuticRuleLib.outcome({ exitCode: exitCode, isError: result ? result.isError : null, output: output });
|
|
2818
|
+
intuticOutcomeStateWrite(file, intuticRuleLib.recordOutcome(read.state || intuticRuleLib.emptyState(), 'action:run_tests', d, Date.now()));
|
|
2819
|
+
} else if (read.exists && read.state && intuticRuleLib.isFileWrite(toolName, input)) {
|
|
2820
|
+
intuticOutcomeStateWrite(file, intuticRuleLib.noteCall(read.state, true));
|
|
2821
|
+
}
|
|
2822
|
+
} catch (e) {}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
/** The real path of p, resolving its deepest existing ancestor when p does not exist yet. */
|
|
2826
|
+
function intuticRealPath(p) {
|
|
2827
|
+
var fs = require('fs'), path = require('path');
|
|
2828
|
+
var abs = path.resolve(p);
|
|
2829
|
+
try { return fs.realpathSync(abs); } catch (e) {}
|
|
2830
|
+
var rest = [], dir = abs;
|
|
2831
|
+
for (;;) {
|
|
2832
|
+
var parent = path.dirname(dir);
|
|
2833
|
+
rest.unshift(path.basename(dir));
|
|
2834
|
+
if (parent === dir) return abs;
|
|
2835
|
+
dir = parent;
|
|
2836
|
+
try { return path.join.apply(path, [fs.realpathSync(dir)].concat(rest)); } catch (e) {}
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/** The workspace root of a call: its cwd walked up to the nearest .git, else the cwd. */
|
|
2841
|
+
function intuticRepoRoot(cwd) {
|
|
2842
|
+
var fs = require('fs'), path = require('path');
|
|
2843
|
+
var d = intuticRealPath(cwd);
|
|
2844
|
+
for (;;) {
|
|
2845
|
+
if (fs.existsSync(path.join(d, '.git'))) return d;
|
|
2846
|
+
var parent = path.dirname(d);
|
|
2847
|
+
if (parent === d) return intuticRealPath(cwd);
|
|
2848
|
+
d = parent;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
/**
|
|
2853
|
+
* The session's approved plan from the daemon's cache, { tools }, or null:
|
|
2854
|
+
* missing, past its #expires, failing its digest or workspace check, or no
|
|
2855
|
+
* APPROVED or EXECUTING plan for the session.
|
|
2856
|
+
*/
|
|
2857
|
+
function intuticApprovedPlan(sessionId, workspaceId) {
|
|
2858
|
+
var fs = require('fs'), os = require('os'), path = require('path');
|
|
2859
|
+
var file = process.env.INTUTIC_APPROVED_PLANS || path.join(os.homedir(), '.intutic', 'hooks', ${JSON.stringify(APPROVED_PLANS_FILE)});
|
|
2860
|
+
var raw;
|
|
2861
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { return null; }
|
|
2862
|
+
var digest = '', ws = '', expires = '', body = [];
|
|
2863
|
+
raw.split('\\n').forEach(function (line) {
|
|
2864
|
+
if (line.indexOf('#digest ') === 0) digest = line.slice(8).trim();
|
|
2865
|
+
else if (line.indexOf('#workspace ') === 0) ws = line.slice(11).trim();
|
|
2866
|
+
else if (line.indexOf('#expires ') === 0) expires = line.slice(9).trim();
|
|
2867
|
+
else if (line && line.charAt(0) !== '#') body.push(line);
|
|
2868
|
+
});
|
|
2869
|
+
if (require('crypto').createHash('sha256').update(body.join('\\n')).digest('hex').slice(0, 32) !== digest) return null;
|
|
2870
|
+
if (ws && workspaceId && ws !== workspaceId) return null;
|
|
2871
|
+
var exp = Date.parse(expires);
|
|
2872
|
+
if (!exp || isNaN(exp) || Date.now() >= exp) return null;
|
|
2873
|
+
var tools = null;
|
|
2874
|
+
for (var i = 0; i < body.length; i++) {
|
|
2875
|
+
var e;
|
|
2876
|
+
try { e = JSON.parse(body[i]); } catch (x) { continue; }
|
|
2877
|
+
if (!e || e.sessionId !== sessionId || (e.state !== 'APPROVED' && e.state !== 'EXECUTING')) continue;
|
|
2878
|
+
tools = (tools || []).concat((Array.isArray(e.tools) ? e.tools : [])
|
|
2879
|
+
.filter(function (t) { return typeof t === 'string'; }).map(function (t) { return t.toLowerCase(); }));
|
|
2880
|
+
}
|
|
2881
|
+
return tools === null ? null : { tools: tools };
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
/**
|
|
2885
|
+
* The trajectory tier: a file change outside the union of scope_paths
|
|
2886
|
+
* (OUT_OF_SCOPE), and under plan_approval a call before the session's plan is
|
|
2887
|
+
* approved (PLAN_NOT_APPROVED) or, strict, off its steps (OFF_PLAN).
|
|
2888
|
+
*/
|
|
2889
|
+
function intuticTrajectoryTier(snap, toolName, toolInput, sessionId, workspaceId) {
|
|
2890
|
+
var path = require('path');
|
|
2891
|
+
var shadow = null;
|
|
2892
|
+
var scopes = snap.gateRecords.filter(function (r) { return r.kind === 'scope_paths'; });
|
|
2893
|
+
var targets = scopes.length > 0 ? intuticRuleLib.changeTargets(toolName, toolInput).map(function (t) { return t.target; }) : [];
|
|
2894
|
+
if (targets.length > 0) {
|
|
2895
|
+
var cwd = _intuticCallCwd || process.cwd();
|
|
2896
|
+
var root = intuticRepoRoot(cwd);
|
|
2897
|
+
var rels = targets.map(function (t) {
|
|
2898
|
+
var rel = path.relative(root, intuticRealPath(path.resolve(intuticRealPath(cwd), t)));
|
|
2899
|
+
var outside = rel === '..' || rel.indexOf('..' + path.sep) === 0 || path.isAbsolute(rel);
|
|
2900
|
+
return { t: t, rel: outside ? null : rel.split(path.sep).join('/') };
|
|
2901
|
+
});
|
|
2902
|
+
var rollouts = ['enforce', 'shadow'];
|
|
2903
|
+
for (var k = 0; k < rollouts.length; k++) {
|
|
2904
|
+
var group = scopes.filter(function (r) { return r.rollout === rollouts[k]; });
|
|
2905
|
+
if (group.length === 0) continue;
|
|
2906
|
+
var union = [];
|
|
2907
|
+
group.forEach(function (r) { union = union.concat(r.scopePaths); });
|
|
2908
|
+
var bad = rels.filter(function (x) { return x.rel === null || !intuticRuleLib.withinScope(x.rel, union); })
|
|
2909
|
+
.map(function (x) { return x.t; });
|
|
2910
|
+
if (bad.length === 0) continue;
|
|
2911
|
+
var v = intuticRuleVerdict(group[0], 'OUT_OF_SCOPE', 'scope_paths.' + group[0].sopTitle,
|
|
2912
|
+
bad.join(', ') + ' is outside scope_paths (' + union.join(', ') + ')', { paths: bad });
|
|
2913
|
+
if (v.verdict === 'block') return v;
|
|
2914
|
+
if (!shadow) shadow = v;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
var plans = snap.gateRecords.filter(function (r) { return r.kind === 'plan_approval'; });
|
|
2918
|
+
if (plans.length > 0 && !intuticRuleLib.planExempt(String(toolName))) {
|
|
2919
|
+
var prolls = ['enforce', 'shadow'];
|
|
2920
|
+
for (var j = 0; j < prolls.length; j++) {
|
|
2921
|
+
var pg = plans.filter(function (r) { return r.rollout === prolls[j]; });
|
|
2922
|
+
if (pg.length === 0) continue;
|
|
2923
|
+
var strict = pg.filter(function (r) { return r.mode === 'strict'; });
|
|
2924
|
+
var r = strict.length > 0 ? strict[0] : pg[0];
|
|
2925
|
+
var level = r.mode;
|
|
2926
|
+
var rid = 'plan_approval.' + r.sopTitle;
|
|
2927
|
+
var pscope = { tools: [String(toolName)] };
|
|
2928
|
+
var pv = null;
|
|
2929
|
+
if (!sessionId) {
|
|
2930
|
+
pv = intuticRuleVerdict(r, 'PLAN_NOT_APPROVED', rid, 'plan_approval: ' + level + ' - ' + INTUTIC_HARNESS + ' sent no session id, so no approved plan can be found for this call', pscope);
|
|
2931
|
+
} else {
|
|
2932
|
+
var plan = intuticApprovedPlan(sessionId, workspaceId);
|
|
2933
|
+
if (plan === null) {
|
|
2934
|
+
pv = intuticRuleVerdict(r, 'PLAN_NOT_APPROVED', rid, 'plan_approval: ' + level + ' - this session has no APPROVED or EXECUTING plan; get its plan approved, then retry', pscope);
|
|
2935
|
+
} else if (level === 'strict' && plan.tools.indexOf(String(toolName).toLowerCase()) === -1) {
|
|
2936
|
+
pv = intuticRuleVerdict(r, 'OFF_PLAN', rid, 'plan_approval: strict - ' + String(toolName) + ' is not a step of the approved plan', pscope);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
if (pv && pv.verdict === 'block') return pv;
|
|
2940
|
+
if (pv && !shadow) shadow = pv;
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
return shadow;
|
|
2944
|
+
}
|
|
2945
|
+
`;
|
|
2946
|
+
}
|
|
1679
2947
|
/**
|
|
1680
2948
|
* The JavaScript gate body.
|
|
1681
2949
|
*
|
|
@@ -1687,6 +2955,38 @@ function intuticChaosKey(toolName, command, target) {
|
|
|
1687
2955
|
export function emitJsGate(opts) {
|
|
1688
2956
|
const floor = staticFloorPatterns();
|
|
1689
2957
|
const deadlineMs = gateDeadlineMs(opts.harness);
|
|
2958
|
+
// The most the decide tier spends on a call (decideBudgetMs in gateLimits.ts).
|
|
2959
|
+
const decideBudget = decideBudgetMs('js', opts.harness);
|
|
2960
|
+
const ask = opts.ask ?? 'none';
|
|
2961
|
+
/**
|
|
2962
|
+
* Asks a person through this harness's {@link AskContract}, for a `reason`
|
|
2963
|
+
* in scope. Exactly the documented fields: a harness that reads a decision
|
|
2964
|
+
* object may reject one with fields it does not know.
|
|
2965
|
+
*/
|
|
2966
|
+
const askWith = () => {
|
|
2967
|
+
switch (ask) {
|
|
2968
|
+
case 'claude-pretooluse':
|
|
2969
|
+
return (` process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }) + '\\n');\n` +
|
|
2970
|
+
` process.exit(0);`);
|
|
2971
|
+
case 'copilot-decision':
|
|
2972
|
+
// The Copilot CLI reads the decision at the top level, VS Code under
|
|
2973
|
+
// hookSpecificOutput.
|
|
2974
|
+
return (` process.stdout.write(JSON.stringify({ permissionDecision: 'ask', permissionDecisionReason: reason,\n` +
|
|
2975
|
+
` hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }) + '\\n');\n` +
|
|
2976
|
+
` process.exit(0);`);
|
|
2977
|
+
case 'cursor-permission':
|
|
2978
|
+
return (` process.stdout.write(JSON.stringify({ permission: 'ask', user_message: reason, agent_message: reason }) + '\\n');\n` +
|
|
2979
|
+
` process.exit(0);`);
|
|
2980
|
+
case 'antigravity-force-ask':
|
|
2981
|
+
return ` process.stdout.write(JSON.stringify({ decision: 'force_ask', reason: reason }) + '\\n');\n process.exit(0);`;
|
|
2982
|
+
case 'throw-ask':
|
|
2983
|
+
return (` var _ask = new Error('[Intutic Governance] ASK: ' + reason);\n` +
|
|
2984
|
+
` _ask.intuticAsk = { reason: reason, ruleId: snap.opaque ? null : dec.ruleId };\n` +
|
|
2985
|
+
` throw _ask;`);
|
|
2986
|
+
default:
|
|
2987
|
+
throw new Error(`gateBody: the ${ask} ask contract is not one a JavaScript gate speaks`);
|
|
2988
|
+
}
|
|
2989
|
+
};
|
|
1690
2990
|
/**
|
|
1691
2991
|
* The refusal, through this harness's contract, for a `reason` in scope.
|
|
1692
2992
|
* `code` and `ruleId` are JavaScript expressions: a JSON decision carries
|
|
@@ -1736,6 +3036,9 @@ export function emitJsGate(opts) {
|
|
|
1736
3036
|
: opts.contract === 'throw'
|
|
1737
3037
|
? ` throw new Error(${code === 'null' ? `'[Intutic Governance] ' + reason` : 'reason'});`
|
|
1738
3038
|
: ` process.exit(2);`);
|
|
3039
|
+
/** An exit-code harness's retry-scope line (§6.2), after the reason; none in OPAQUE. */
|
|
3040
|
+
const retryScopeStderr = ` const _retry = intuticRuleLib.retryLine(_mode, v.scope);\n` +
|
|
3041
|
+
` if (_retry) { try { console.error(_retry); } catch (e) {} }\n`;
|
|
1739
3042
|
return `
|
|
1740
3043
|
// ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
|
|
1741
3044
|
// Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
|
|
@@ -1750,10 +3053,16 @@ ${jsGuardTable('INTUTIC_FLOOR', floor)}
|
|
|
1750
3053
|
// had already drifted from the in-process one on null handling.
|
|
1751
3054
|
${NORMALISE_CONTRACT.jsSource}
|
|
1752
3055
|
|
|
1753
|
-
${
|
|
3056
|
+
${JS_DECIDE_LIB}
|
|
3057
|
+
|
|
3058
|
+
${JS_RULE_LIB}
|
|
3059
|
+
|
|
3060
|
+
${jsSnapshotLoader(GATE_EVALUATES_DECIDE.js, Object.keys(GATE_EVALUATES_RULE_RECORDS.js).filter((k) => GATE_EVALUATES_RULE_RECORDS.js[k]), true)}
|
|
1754
3061
|
|
|
1755
3062
|
${jsHoldHelpers(opts.reviewRequestFile)}
|
|
1756
3063
|
|
|
3064
|
+
${jsRuleTiers(opts.harness)}
|
|
3065
|
+
|
|
1757
3066
|
${JS_CHAOS_HELPERS}
|
|
1758
3067
|
|
|
1759
3068
|
/**
|
|
@@ -1809,8 +3118,10 @@ ${MCP_ALLOWLIST_JS_SOURCE}
|
|
|
1809
3118
|
${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
|
|
1810
3119
|
|
|
1811
3120
|
/**
|
|
1812
|
-
* Evaluates one tool call
|
|
1813
|
-
*
|
|
3121
|
+
* Evaluates one tool call's deterministic tiers: returns the call's tool name
|
|
3122
|
+
* (as the rules read it) and the snapshot to allow; refuses via this
|
|
3123
|
+
* harness's contract otherwise. {@link intuticGateWithDecide} and
|
|
3124
|
+
* {@link intuticGate} run it first.
|
|
1814
3125
|
*
|
|
1815
3126
|
* Takes its inputs as parameters rather than closing over the writer's locals:
|
|
1816
3127
|
* the eight JS writers name and shape those differently, and a gate that
|
|
@@ -1823,7 +3134,7 @@ ${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
|
|
|
1823
3134
|
* matchSopRule and the intutic-clawde gate port pin — and the gate serializes
|
|
1824
3135
|
* it itself so no writer can hand it a differently-shaped string.
|
|
1825
3136
|
*/
|
|
1826
|
-
function
|
|
3137
|
+
function intuticGateDeterministic(toolName, target, command, record, workspaceId, toolInput, sessionId) {
|
|
1827
3138
|
// A chaos run's record of this call (see intuticChaosDecide). A plan that is
|
|
1828
3139
|
// set but unreadable refuses, as an unreadable payload does: an experiment
|
|
1829
3140
|
// run without the faults it asked for reports a resilience it never measured.
|
|
@@ -1865,7 +3176,7 @@ ${opts.contract === 'throw'
|
|
|
1865
3176
|
return _intuticGateRules(toolName, target, command, record, workspaceId, toolInput, sessionId);
|
|
1866
3177
|
};
|
|
1867
3178
|
try {
|
|
1868
|
-
require('vm').runInThisContext('__intuticGateRules()', { timeout: _deadlineMs });
|
|
3179
|
+
return require('vm').runInThisContext('__intuticGateRules()', { timeout: _deadlineMs });
|
|
1869
3180
|
} catch (err) {
|
|
1870
3181
|
if (!err || err.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw err;
|
|
1871
3182
|
var reason = 'GATE_DEADLINE — the gate did not finish evaluating this call within ${deadlineMs / 1000} s, and refuses it rather than let the hook timeout allow it';
|
|
@@ -1873,10 +3184,14 @@ ${opts.contract === 'throw'
|
|
|
1873
3184
|
try { record('tool_blocked', toolName, reason); } catch (e) {}
|
|
1874
3185
|
${refuseWith("'GATE_DEADLINE'", 'null')}
|
|
1875
3186
|
}
|
|
3187
|
+
}
|
|
1876
3188
|
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
3189
|
+
/**
|
|
3190
|
+
* Every tier allowed the call. A writer that runs the gate once per target of
|
|
3191
|
+
* one call (OpenClaw's apply_patch) passes more for all but the last, so the
|
|
3192
|
+
* call is reported, and faulted, once.
|
|
3193
|
+
*/
|
|
3194
|
+
function intuticGateAllowed(toolName, target, command, more) {
|
|
1880
3195
|
if (more) {
|
|
1881
3196
|
if (_intuticChaosCall) _intuticChaosCall.more = true;
|
|
1882
3197
|
return;
|
|
@@ -1893,6 +3208,164 @@ ${refuseFault("'HELD'")}
|
|
|
1893
3208
|
${refuseFault('null')}
|
|
1894
3209
|
}
|
|
1895
3210
|
|
|
3211
|
+
// ── Decide tier (gate body v22) ──────────────────────────────────────────────
|
|
3212
|
+
// After every deterministic tier allowed the call, and only then: the
|
|
3213
|
+
// snapshot's decide rules on this tool, asked of the self-hosted engine
|
|
3214
|
+
// (~/.intutic/decide/engine.json, INTUTIC_DECIDE_* in the environment) within
|
|
3215
|
+
// ${decideBudget} ms, less what the deadline has left. A rule refuses, asks a person
|
|
3216
|
+
// or allows; an engine that cannot answer refuses an enforcing rule
|
|
3217
|
+
// (GOVERNANCE_UNAVAILABLE) and lets a shadow rule's call on. A rule whose
|
|
3218
|
+
// estimate (the engine's per-call p95 \`intutic doctor\` stored) does not fit
|
|
3219
|
+
// the time left is not asked: an enforcing one escalates.
|
|
3220
|
+
|
|
3221
|
+
/**
|
|
3222
|
+
* The engine, its URL, the canary cache and the stored p95, read for each call
|
|
3223
|
+
* that needs them, and the resolved configuration (cfg) for the decide log.
|
|
3224
|
+
*/
|
|
3225
|
+
function intuticDecideDeps() {
|
|
3226
|
+
const fs = require('fs'), os = require('os'), path = require('path');
|
|
3227
|
+
const dir = path.join(os.homedir(), '.intutic', 'decide');
|
|
3228
|
+
var engine, url = '', q95 = null, parallel = 1, resolved = null;
|
|
3229
|
+
try {
|
|
3230
|
+
var file = null;
|
|
3231
|
+
try { file = JSON.parse(fs.readFileSync(path.join(dir, 'engine.json'), 'utf8')); } catch (e) {
|
|
3232
|
+
if (!e || e.code !== 'ENOENT') throw new intuticDecideLib.EngineError('not_configured', 'engine.json does not read: ' + String((e && e.message) || e));
|
|
3233
|
+
}
|
|
3234
|
+
q95 = intuticDecideLib.storedQ95(file);
|
|
3235
|
+
var cfg = intuticDecideLib.resolveConfig(file, process.env);
|
|
3236
|
+
resolved = cfg;
|
|
3237
|
+
url = cfg.url;
|
|
3238
|
+
parallel = cfg.parallel;
|
|
3239
|
+
engine = intuticDecideLib.createEngine(cfg);
|
|
3240
|
+
} catch (e) {
|
|
3241
|
+
engine = e instanceof intuticDecideLib.EngineError ? e : new intuticDecideLib.EngineError('not_configured', String((e && e.message) || e));
|
|
3242
|
+
}
|
|
3243
|
+
return { engine: engine, engineUrl: url, canary: intuticDecideLib.canaryStore(path.join(dir, 'canary.json'), fs),
|
|
3244
|
+
sha256Hex: function (t) { return require('crypto').createHash('sha256').update(t, 'utf8').digest('hex'); },
|
|
3245
|
+
q95Ms: q95, parallel: parallel, cfg: resolved };
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
// The patterns the decide log's arguments excerpt is redacted with
|
|
3249
|
+
// (decideLogRedactionSources in @intutic/shared-types).
|
|
3250
|
+
const INTUTIC_DECIDE_REDACT = ${JSON.stringify(decideLogRedactionSources())};
|
|
3251
|
+
|
|
3252
|
+
/**
|
|
3253
|
+
* Appends this call's decide record to ~/.intutic/logs/decide-<date>.jsonl and
|
|
3254
|
+
* updates each rule's drift stats, as \`intutic decide check\` does for the
|
|
3255
|
+
* shell gates. Never throws: a log that cannot be written never changes the
|
|
3256
|
+
* decision.
|
|
3257
|
+
*/
|
|
3258
|
+
function intuticDecideLog(ev, rules, call, deps) {
|
|
3259
|
+
try {
|
|
3260
|
+
const fs = require('fs'), os = require('os'), crypto = require('crypto');
|
|
3261
|
+
intuticDecideLib.writeLog(ev, rules, { atMs: Date.now(), harness: ${JSON.stringify(opts.harness)},
|
|
3262
|
+
workspaceId: call.workspaceId || '', sessionId: call.sessionId || '', tool: call.tool, toolInput: call.toolInput,
|
|
3263
|
+
cwd: call.cwd, budgetMs: call.budgetMs, latencyMs: Date.now() - call.startedMs,
|
|
3264
|
+
engine: intuticDecideLib.logEngine(deps.cfg, ev), redact: INTUTIC_DECIDE_REDACT },
|
|
3265
|
+
intuticDecideLib.logDeps(fs, os.homedir(), function (t) { return crypto.createHash('sha256').update(t, 'utf8').digest('hex'); }));
|
|
3266
|
+
} catch (e) {}
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
/**
|
|
3270
|
+
* The decide tier's decision on a call the deterministic tiers allowed:
|
|
3271
|
+
* { outcome, code, ruleId, via, reason }, or null when no decide rule names
|
|
3272
|
+
* the tool. Records each shadow rule's finding. Never throws: a tier that
|
|
3273
|
+
* cannot finish is unavailable, which refuses unless every rule on the tool
|
|
3274
|
+
* is stamped shadow.
|
|
3275
|
+
*/
|
|
3276
|
+
async function intuticDecideTier(toolName, toolInput, snap, record, startedMs, opts, workspaceId, sessionId) {
|
|
3277
|
+
const rules = snap.decideRules.filter(function (r) { return r.ir.on === 'tool_call' && (r.ir.tools || []).indexOf(toolName) !== -1; });
|
|
3278
|
+
if (rules.length === 0) return null;
|
|
3279
|
+
${opts.contract === 'throw'
|
|
3280
|
+
? ` var _elapsed = Date.now() - startedMs;`
|
|
3281
|
+
: ` var _elapsed = Math.round(process.uptime() * 1000);`}
|
|
3282
|
+
var _budget = Math.min(${decideBudget}, ${deadlineMs} - _elapsed - ${DECIDE_BUDGET_MARGIN_MS});
|
|
3283
|
+
if (_budget < ${DECIDE_MIN_CALL_MS}) {
|
|
3284
|
+
return intuticDecideLib.unavailable(rules, toolName, 'no time was left for the decide rules on ' + toolName + ' within the gate deadline');
|
|
3285
|
+
}
|
|
3286
|
+
var _timer = null, ev = null, _asked = Date.now();
|
|
3287
|
+
var _call = { tool: toolName, toolInput: toolInput == null ? {} : toolInput, cwd: (opts && opts.cwd) || process.cwd(),
|
|
3288
|
+
budgetMs: _budget, startedMs: _asked, workspaceId: workspaceId, sessionId: sessionId };
|
|
3289
|
+
var _deps = null;
|
|
3290
|
+
try {
|
|
3291
|
+
_deps = intuticDecideDeps();
|
|
3292
|
+
ev = await Promise.race([
|
|
3293
|
+
intuticDecideLib.evaluate({ deterministic: 'allow', tool: toolName, toolInput: _call.toolInput,
|
|
3294
|
+
cwd: _call.cwd, lastUserTurn: (opts && opts.lastUserTurn) || null, rules: rules,
|
|
3295
|
+
deadlineMs: _asked + _budget }, _deps),
|
|
3296
|
+
new Promise(function (resolve) { _timer = setTimeout(function () { resolve(null); }, _budget + ${DECIDE_BUDGET_MARGIN_MS / 2}); }),
|
|
3297
|
+
]);
|
|
3298
|
+
} catch (e) {
|
|
3299
|
+
ev = null;
|
|
3300
|
+
} finally {
|
|
3301
|
+
if (_timer) clearTimeout(_timer);
|
|
3302
|
+
}
|
|
3303
|
+
if (!ev) return intuticDecideLib.unavailable(rules, toolName, 'the decide rules on ' + toolName + ' were not evaluated within ' + _budget + ' ms');
|
|
3304
|
+
intuticDecideLog(ev, rules, _call, _deps);
|
|
3305
|
+
for (const f of intuticDecideLib.shadowFindings(ev, rules)) {
|
|
3306
|
+
try { record('tool_would_block', toolName, f.reason + ' [' + f.ruleId + ']'); } catch (e) {}
|
|
3307
|
+
}
|
|
3308
|
+
return intuticDecideLib.decision(ev, rules);
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
/**
|
|
3312
|
+
* Acts on a decide decision that is not an allow: asks a person through this
|
|
3313
|
+
* harness's ask where it has one and the call can ask (\`canAsk\`), and
|
|
3314
|
+
* otherwise refuses, an escalation with DECIDE_ESCALATED.
|
|
3315
|
+
*/
|
|
3316
|
+
function intuticDecideAct(dec, toolName, snap, record, canAsk) {
|
|
3317
|
+
var _full = dec.reason + (dec.ruleId ? ' [' + dec.ruleId + ']' : '');
|
|
3318
|
+
${ask === 'none'
|
|
3319
|
+
? ` // ${opts.harness} cannot ask a person on this path, so an escalation refuses.`
|
|
3320
|
+
: ` if (dec.outcome === 'escalate' && canAsk) {
|
|
3321
|
+
var reason = intuticShown(snap, _full);
|
|
3322
|
+
try { console.error('[Intutic Governance] ASK: ' + reason); } catch (e) {}
|
|
3323
|
+
try { record('tool_flagged', toolName, 'DECIDE_ESCALATED — asked a person: ' + _full); } catch (e) {}
|
|
3324
|
+
intuticChaosDecide(toolName, 'hold', dec.ruleId, dec.ruleId != null);
|
|
3325
|
+
${askWith()}
|
|
3326
|
+
}`}
|
|
3327
|
+
var reason = intuticShown(snap, _full);
|
|
3328
|
+
try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
|
|
3329
|
+
try { record('tool_blocked', toolName, _full); } catch (e) {}
|
|
3330
|
+
${refuseWith('dec.code', 'dec.ruleId', '', 'toolName', '(snap.opaque ? null : dec.ruleId)')}
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
/**
|
|
3334
|
+
* Evaluates one tool call: the deterministic tiers, then the decide tier.
|
|
3335
|
+
* Resolves to allow; refuses, or asks, via this harness's contracts.
|
|
3336
|
+
* \`opts\`: \`more\` (as {@link intuticGateAllowed}), \`canAsk\` (false where
|
|
3337
|
+
* this call's hook cannot ask), \`cwd\`, \`lastUserTurn\`, and \`before\`, the
|
|
3338
|
+
* writer's own deterministic checks, run after the shared ones and before
|
|
3339
|
+
* any engine call.
|
|
3340
|
+
*/
|
|
3341
|
+
async function intuticGateWithDecide(toolName, target, command, record, workspaceId, toolInput, sessionId, opts) {
|
|
3342
|
+
opts = opts || {};
|
|
3343
|
+
var _started = Date.now();
|
|
3344
|
+
_intuticCallCwd = opts.cwd || null;
|
|
3345
|
+
var _d = intuticGateDeterministic(toolName, target, command, record, workspaceId, toolInput, sessionId);
|
|
3346
|
+
if (typeof opts.before === 'function') opts.before();
|
|
3347
|
+
var _dec = await intuticDecideTier(_d.toolName, toolInput, _d.snap, record, _started, opts, workspaceId, sessionId);
|
|
3348
|
+
if (_dec && _dec.outcome !== 'allow') intuticDecideAct(_dec, _d.toolName, _d.snap, record, opts.canAsk !== false);
|
|
3349
|
+
intuticGateAllowed(toolName, target, command, opts.more);
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
/**
|
|
3353
|
+
* The deterministic tiers alone, synchronously. It cannot ask the decide
|
|
3354
|
+
* engine, so a call a decide rule names is refused here, unless every such
|
|
3355
|
+
* rule is stamped shadow: a caller that has not moved to
|
|
3356
|
+
* {@link intuticGateWithDecide} never opens what the rule's skipped fallback
|
|
3357
|
+
* line would have refused. \`more\` as {@link intuticGateAllowed}.
|
|
3358
|
+
*/
|
|
3359
|
+
function intuticGate(toolName, target, command, record, workspaceId, toolInput, sessionId, more) {
|
|
3360
|
+
_intuticCallCwd = null;
|
|
3361
|
+
var _d = intuticGateDeterministic(toolName, target, command, record, workspaceId, toolInput, sessionId);
|
|
3362
|
+
if (!more) {
|
|
3363
|
+
var _dec = intuticDecideLib.unavailable(_d.snap.decideRules, _d.toolName, 'this gate entry cannot evaluate the decide rules on ' + _d.toolName);
|
|
3364
|
+
if (_dec.outcome !== 'allow') intuticDecideAct(_dec, _d.toolName, _d.snap, record, false);
|
|
3365
|
+
}
|
|
3366
|
+
intuticGateAllowed(toolName, target, command, more);
|
|
3367
|
+
}
|
|
3368
|
+
|
|
1896
3369
|
/**
|
|
1897
3370
|
* The subjects of this call a rule matches, before its argument condition:
|
|
1898
3371
|
* for a phrase rule, the command when its phrases are in it. The context c
|
|
@@ -1904,7 +3377,7 @@ function intuticRuleSubjects(rule, c) {
|
|
|
1904
3377
|
// the raw command — never as a regex, which backtracks on crafted input.
|
|
1905
3378
|
if (rule.subject === 'phrase') {
|
|
1906
3379
|
if (c.words === null) c.words = phraseText(c.command);
|
|
1907
|
-
return intuticPhraseRule(rule.re.source, c.words) ? [c.nCommand] : [];
|
|
3380
|
+
return intuticPhraseRule(rule.src != null ? rule.src : rule.re.source, c.words) ? [c.nCommand] : [];
|
|
1908
3381
|
}
|
|
1909
3382
|
// Each field is tested separately rather than concatenated — joining them
|
|
1910
3383
|
// lets a pattern match across the seam between two innocuous values.
|
|
@@ -1969,6 +3442,9 @@ function _intuticGateRules(toolName, target, command, record, workspaceId, toolI
|
|
|
1969
3442
|
: 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
|
|
1970
3443
|
try { record('snapshot_' + snap.state, toolName, _msg); } catch (e) {}
|
|
1971
3444
|
}
|
|
3445
|
+
if (snap.format > ${GATE_READS_FORMAT}) {
|
|
3446
|
+
try { record('snapshot_format_newer', toolName, ${JSON.stringify(FORMAT_NEWER_MESSAGE)}.replace('{n}', String(snap.format))); } catch (e) {}
|
|
3447
|
+
}
|
|
1972
3448
|
// A chaos run's no_destructive_attempt probe reads every destructive.* rule,
|
|
1973
3449
|
// the ones the escape hatch skips below included.
|
|
1974
3450
|
const _chaosDestructive = intuticChaos().plan
|
|
@@ -1997,7 +3473,31 @@ function _intuticGateRules(toolName, target, command, record, workspaceId, toolI
|
|
|
1997
3473
|
}
|
|
1998
3474
|
}
|
|
1999
3475
|
const rules = INTUTIC_FLOOR.concat(snap.rules);
|
|
3476
|
+
// A rule record's verdict (gate body v23): shadow records the finding,
|
|
3477
|
+
// block refuses through this harness's contract.
|
|
3478
|
+
const _intuticRuleAct = function (v) {
|
|
3479
|
+
if (!v) return;
|
|
3480
|
+
if (v.verdict === 'shadow') {
|
|
3481
|
+
intuticChaosNote(v.ruleId, false);
|
|
3482
|
+
try { record('tool_would_block', toolName, v.reason + ' [' + v.ruleId + ']'); } catch (e) {}
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
const _full = v.reason + ' [' + v.ruleId + ']';
|
|
3486
|
+
const reason = intuticShown(snap, _full);
|
|
3487
|
+
// Where the refusal applies: bounded, and dropped in an
|
|
3488
|
+
// OPAQUE workspace, as the rule is.
|
|
3489
|
+
const _mode = snap.opaque ? 'OPAQUE' : 'TRANSPARENT';
|
|
3490
|
+
const _detail = intuticRuleLib.detail(_mode, v.scope, null);
|
|
3491
|
+
try { console.error('[Intutic Governance] BLOCKED: ' + reason + ' (refusal code ' + v.code + ')'); } catch (e) {}
|
|
3492
|
+
${opts.contract === 'exit2' ? retryScopeStderr : ''} try { record('tool_blocked', toolName, _full); } catch (e) {}
|
|
3493
|
+
${refuseWith('v.code', 'v.ruleId', 'scope: _detail.scope, evidence: _detail.evidence', 'toolName', '(snap.opaque ? null : v.ruleId)')}
|
|
3494
|
+
};
|
|
3495
|
+
// The tier order (gate body v23): every refusal first ('det'), the holds
|
|
3496
|
+
// after the rule records and the MCP checks ('hold'). A hold is only ever
|
|
3497
|
+
// decided on a call nothing deterministic refused.
|
|
3498
|
+
const _intuticApplyRules = function (pass) {
|
|
2000
3499
|
for (const rule of rules) {
|
|
3500
|
+
if (pass === 'det' ? rule.severity === 'hold' : rule.severity !== 'hold') continue;
|
|
2001
3501
|
for (const subject of intuticRuleSubjects(rule, ctx)) {
|
|
2002
3502
|
// The argument condition of a WHERE rule: the tool-name half has
|
|
2003
3503
|
// matched, and the rule fires only if the argPattern also matches the
|
|
@@ -2041,7 +3541,7 @@ ${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId', 'toolName', '(snap.opaq
|
|
|
2041
3541
|
// The rollback rung's storage half: this call was flagged and is about
|
|
2042
3542
|
// to proceed, which is the only instant the prior bytes still exist.
|
|
2043
3543
|
// No-op unless the workspace opted in.
|
|
2044
|
-
try { _intuticCapturePreImage(toolName, toolInput, rule.id); } catch (e) {}
|
|
3544
|
+
try { _intuticCapturePreImage(toolName, toolInput, rule.id, nActions); } catch (e) {}
|
|
2045
3545
|
continue;
|
|
2046
3546
|
}
|
|
2047
3547
|
// The rule's own reason, not a generic one: hookEvents.resolveSeverity
|
|
@@ -2053,6 +3553,10 @@ ${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId', 'toolName', '(snap.opaq
|
|
|
2053
3553
|
${refuseWith('intuticRuleCode(rule)', 'rule.id', '', 'toolName', '(snap.opaque ? null : rule.id)')}
|
|
2054
3554
|
}
|
|
2055
3555
|
}
|
|
3556
|
+
};
|
|
3557
|
+
_intuticApplyRules('det');
|
|
3558
|
+
// requires_outcome (gate body v23), among the deterministic refusals.
|
|
3559
|
+
_intuticRuleAct(intuticOutcomeTier(snap, toolName, nActions, toolInput, sessionId));
|
|
2056
3560
|
|
|
2057
3561
|
// An unverified snapshot admits no MCP server (gate body v17): neither
|
|
2058
3562
|
// record in it can be vouched for, and a deleted one looks like one never
|
|
@@ -2110,6 +3614,12 @@ ${refuseWith('_mcp.code', '_mcp.ruleId', '', 'toolName', '(snap.opaque ? null :
|
|
|
2110
3614
|
}
|
|
2111
3615
|
}
|
|
2112
3616
|
}
|
|
3617
|
+
// The holds, after every refusal; then the trajectory tier (gate body v23).
|
|
3618
|
+
_intuticApplyRules('hold');
|
|
3619
|
+
_intuticRuleAct(intuticTrajectoryTier(snap, toolName, toolInput, sessionId, workspaceId || ''));
|
|
3620
|
+
// Allowed by every deterministic tier: the decide tier reads the tool name
|
|
3621
|
+
// as the rules did, and the snapshot's decide rules.
|
|
3622
|
+
return { toolName: toolName, snap: snap };
|
|
2113
3623
|
}
|
|
2114
3624
|
// ── end Intutic gate body ────────────────────────────────────────────────────
|
|
2115
3625
|
`;
|
|
@@ -2187,7 +3697,7 @@ function _intuticTargetPath(input) {
|
|
|
2187
3697
|
* convenience layered on top of it, and inverting that would make the feature
|
|
2188
3698
|
* a new way to break the user's workflow.
|
|
2189
3699
|
*/
|
|
2190
|
-
function _intuticCapturePreImage(toolName, input, ruleId) {
|
|
3700
|
+
function _intuticCapturePreImage(toolName, input, ruleId, actions) {
|
|
2191
3701
|
try {
|
|
2192
3702
|
if (!_intuticRollbackEnabled()) return;
|
|
2193
3703
|
const rel = _intuticTargetPath(input);
|
|
@@ -2253,6 +3763,11 @@ function _intuticCapturePreImage(toolName, input, ruleId) {
|
|
|
2253
3763
|
ruleId: ruleId || '',
|
|
2254
3764
|
workspaceId: _intuticWsId,
|
|
2255
3765
|
};
|
|
3766
|
+
// The action tokens the gate classified this call to:
|
|
3767
|
+
// intutic rollback sends them on tool_reverted, so a revert counts
|
|
3768
|
+
// against the action and not only the tool.
|
|
3769
|
+
var _acts = String(actions || '').split(' ').filter(function (t) { return t.indexOf('action:') === 0; });
|
|
3770
|
+
if (_acts.length > 0) entry.actions = _acts;
|
|
2256
3771
|
fs.appendFileSync(
|
|
2257
3772
|
path.join(_INTUTIC_ROLLBACK_DIR, 'manifest.jsonl'),
|
|
2258
3773
|
JSON.stringify(entry) + '\\n',
|
|
@@ -2332,7 +3847,11 @@ export function emitJsFailClosedPrelude(opts) {
|
|
|
2332
3847
|
// stdout-cancel harnesses, also an allow. Everything that executes after this
|
|
2333
3848
|
// line is covered; a SyntaxError in this file itself is the one thing that is
|
|
2334
3849
|
// not, because no statement in a file that does not parse ever runs.
|
|
3850
|
+
// A post-tool invocation (--event post, gate body v23) records a tool's result
|
|
3851
|
+
// and never refuses: the tool has already run. Every exit is 0 there.
|
|
3852
|
+
var _intuticPostMode = process.argv[2] === '--event' && process.argv[3] === 'post';
|
|
2335
3853
|
function _intuticCrash(err) {
|
|
3854
|
+
if (_intuticPostMode) process.exit(0);
|
|
2336
3855
|
${crashBody}
|
|
2337
3856
|
}
|
|
2338
3857
|
process.on('uncaughtException', _intuticCrash);
|
|
@@ -2354,6 +3873,8 @@ process.on('unhandledRejection', _intuticCrash);`;
|
|
|
2354
3873
|
*/
|
|
2355
3874
|
export const SHELL_EXTRACT = `
|
|
2356
3875
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
3876
|
+
# A post-tool invocation records nothing without python3, and never refuses.
|
|
3877
|
+
if [ "\${INTUTIC_POST_MODE:-0}" = "1" ]; then exit 0; fi
|
|
2357
3878
|
echo "[Intutic Governance] BLOCKED: python3 is required to evaluate tool calls and was not found on PATH." >&2
|
|
2358
3879
|
echo "[Intutic Governance] Install python3, or unset the Intutic hooks to run ungoverned." >&2
|
|
2359
3880
|
exit 2
|
|
@@ -2690,6 +4211,12 @@ def _intutic_evaluate(text):
|
|
|
2690
4211
|
*/
|
|
2691
4212
|
export function emitN8nWorkflowGate() {
|
|
2692
4213
|
const floor = staticFloorPatterns();
|
|
4214
|
+
// The decide tier's budget and deadline (decideBudgetMs in gateLimits.ts): the host waits
|
|
4215
|
+
// without a limit, so the deadline is a hook's under a 10 s timeout.
|
|
4216
|
+
const budget = decideBudgetMs('js', null);
|
|
4217
|
+
const deadline = HOOK_TIMEOUT_SECONDS * 1000 - GATE_DEADLINE_MARGIN_MS;
|
|
4218
|
+
const margin = DECIDE_BUDGET_MARGIN_MS;
|
|
4219
|
+
const minCall = DECIDE_MIN_CALL_MS;
|
|
2693
4220
|
return `
|
|
2694
4221
|
// ── Intutic gate body v${GATE_VERSION} — harness: n8n (workflow-level) ───────
|
|
2695
4222
|
// Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
|
|
@@ -2707,7 +4234,9 @@ ${NORMALISE_CONTRACT.jsSource}
|
|
|
2707
4234
|
// The phrase matcher (@intutic/shared-types phrases.ts), for phrase rules.
|
|
2708
4235
|
${PHRASES_JS_SOURCE}
|
|
2709
4236
|
|
|
2710
|
-
${
|
|
4237
|
+
${JS_DECIDE_LIB}
|
|
4238
|
+
|
|
4239
|
+
${jsSnapshotLoader(GATE_EVALUATES_DECIDE.n8n)}
|
|
2711
4240
|
|
|
2712
4241
|
${JS_OPAQUE_HELPERS}
|
|
2713
4242
|
|
|
@@ -2743,6 +4272,9 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
|
|
|
2743
4272
|
: 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
|
|
2744
4273
|
try { record('snapshot_' + snap.state, 'n8n:' + wfName, _msg); } catch (e) {}
|
|
2745
4274
|
}
|
|
4275
|
+
if (snap.format > ${GATE_READS_FORMAT}) {
|
|
4276
|
+
try { record('snapshot_format_newer', 'n8n:' + wfName, ${JSON.stringify(FORMAT_NEWER_MESSAGE)}.replace('{n}', String(snap.format))); } catch (e) {}
|
|
4277
|
+
}
|
|
2746
4278
|
if (disabled) snap.rules = snap.rules.filter(function (r) { return r.id.indexOf('destructive.') !== 0; });
|
|
2747
4279
|
const rules = INTUTIC_FLOOR.concat(snap.rules);
|
|
2748
4280
|
|
|
@@ -2824,7 +4356,7 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
|
|
|
2824
4356
|
// parameter string — never as a regex, which backtracks on crafted input.
|
|
2825
4357
|
if (rule.subject === 'phrase') {
|
|
2826
4358
|
if (leafWords === null) leafWords = _leaves.map(phraseText);
|
|
2827
|
-
var _phrases = rule.re.source.split('|');
|
|
4359
|
+
var _phrases = (rule.src != null ? rule.src : rule.re.source).split('|');
|
|
2828
4360
|
if (!leafWords.some(function (w) { return _phrases.some(function (p) { return hasPhrase(w, p, true); }); })) continue;
|
|
2829
4361
|
}
|
|
2830
4362
|
// A node has no separate command/target — its parameters are both. A
|
|
@@ -2866,6 +4398,106 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
|
|
|
2866
4398
|
}
|
|
2867
4399
|
}
|
|
2868
4400
|
}
|
|
4401
|
+
// Allowed by every deterministic tier: the decide tier reads the nodes and
|
|
4402
|
+
// the snapshot's decide rules.
|
|
4403
|
+
return { snap: snap, nodes: nodes };
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
// ── Decide tier (gate body v22) ──────────────────────────────────────────────
|
|
4407
|
+
// After every deterministic tier allowed the workflow, and only then: each
|
|
4408
|
+
// node whose type (or its basename, as the tool rules read it) a decide rule
|
|
4409
|
+
// names goes to \`intutic decide check --stdin\`, which asks the self-hosted
|
|
4410
|
+
// engine within ${budget} ms less what the deadline has left. n8n cannot ask
|
|
4411
|
+
// a person, so an escalation refuses (DECIDE_ESCALATED). A CLI that is
|
|
4412
|
+
// missing, fails, times out or answers what this gate cannot read is
|
|
4413
|
+
// unavailable, which refuses unless every rule on the node is stamped shadow.
|
|
4414
|
+
|
|
4415
|
+
const INTUTIC_DECIDE_CODES = { allow: [null], refuse: ['DECIDE_REFUSED', 'DECIDE_ESCALATED', 'GOVERNANCE_UNAVAILABLE'], escalate: ['DECIDE_ESCALATED'] };
|
|
4416
|
+
|
|
4417
|
+
/** Unavailable: a refusal unless every rule on the tool is stamped shadow, whose allow keeps the reason to record. */
|
|
4418
|
+
function intuticDecideUnavailable(rules, tool, why) {
|
|
4419
|
+
const d = intuticDecideLib.unavailable(rules, tool, why);
|
|
4420
|
+
if (d.outcome === 'allow') d.reason = why;
|
|
4421
|
+
return d;
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
/** One node's decide check through the CLI: a decision { outcome, code, ruleId, reason }. Never throws. */
|
|
4425
|
+
function intuticDecideCheck(tool, toolInput, budgetMs, rules) {
|
|
4426
|
+
const unavailable = function (why) { return intuticDecideUnavailable(rules, tool, why); };
|
|
4427
|
+
return new Promise(function (resolve) {
|
|
4428
|
+
var child, out = '', done = false, timer = null;
|
|
4429
|
+
const finish = function (d) {
|
|
4430
|
+
if (done) return;
|
|
4431
|
+
done = true;
|
|
4432
|
+
if (timer) clearTimeout(timer);
|
|
4433
|
+
resolve(d);
|
|
4434
|
+
};
|
|
4435
|
+
try {
|
|
4436
|
+
child = require('child_process').spawn('intutic', ['decide', 'check', '--stdin'], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
4437
|
+
} catch (e) {
|
|
4438
|
+
return finish(unavailable('intutic decide check could not run (' + String((e && e.code) || e) + ')'));
|
|
4439
|
+
}
|
|
4440
|
+
timer = setTimeout(function () {
|
|
4441
|
+
try { child.kill('SIGKILL'); } catch (e) {}
|
|
4442
|
+
finish(unavailable('intutic decide check did not answer within ' + budgetMs + ' ms'));
|
|
4443
|
+
}, budgetMs);
|
|
4444
|
+
child.on('error', function (e) {
|
|
4445
|
+
finish(unavailable(e && e.code === 'ENOENT'
|
|
4446
|
+
? 'the decide rules on ' + tool + ' need the intutic CLI, which is not on PATH'
|
|
4447
|
+
: 'intutic decide check could not run (' + String((e && e.code) || e) + ')'));
|
|
4448
|
+
});
|
|
4449
|
+
child.stdout.on('data', function (c) { out += c; });
|
|
4450
|
+
child.on('close', function (code) {
|
|
4451
|
+
if (code !== 0) return finish(unavailable('intutic decide check exited ' + code));
|
|
4452
|
+
var r = null;
|
|
4453
|
+
try { r = JSON.parse(out); } catch (e) {}
|
|
4454
|
+
const ok = r && typeof r === 'object' && r.v === 1 && Object.prototype.hasOwnProperty.call(INTUTIC_DECIDE_CODES, r.outcome) &&
|
|
4455
|
+
INTUTIC_DECIDE_CODES[r.outcome].indexOf(r.code === undefined ? null : r.code) !== -1 &&
|
|
4456
|
+
(r.ruleId === null || r.ruleId === undefined || (typeof r.ruleId === 'string' && r.ruleId.indexOf('decide.') === 0)) &&
|
|
4457
|
+
(r.reason === undefined || typeof r.reason === 'string');
|
|
4458
|
+
if (!ok) return finish(unavailable('intutic decide check gave an answer this gate does not read'));
|
|
4459
|
+
finish({ outcome: r.outcome, code: r.code || null, ruleId: r.ruleId || null, reason: String(r.reason || '') });
|
|
4460
|
+
});
|
|
4461
|
+
child.stdin.on('error', function () {});
|
|
4462
|
+
child.stdin.end(JSON.stringify({ v: 1, harness: 'n8n', tool: tool, toolInput: toolInput, cwd: process.cwd(), sessionId: '',
|
|
4463
|
+
budgetMs: budgetMs, lastUserTurn: null }));
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
/**
|
|
4468
|
+
* Evaluates one workflow: the deterministic tiers, then each node a decide
|
|
4469
|
+
* rule names. Resolves to allow; throws to abort the execution.
|
|
4470
|
+
*/
|
|
4471
|
+
async function intuticGateWorkflowWithDecide(workflow, record, workspaceId) {
|
|
4472
|
+
const started = Date.now();
|
|
4473
|
+
const r = intuticGateWorkflow(workflow, record, workspaceId);
|
|
4474
|
+
if (r.snap.decideRules.length === 0) return;
|
|
4475
|
+
for (const node of r.nodes) {
|
|
4476
|
+
if (!node || typeof node !== 'object') continue;
|
|
4477
|
+
const nodeType = String(node.type || '');
|
|
4478
|
+
const nodeName = String(node.name || nodeType || 'node');
|
|
4479
|
+
const base = nodeType.indexOf('.') >= 0 ? nodeType.slice(nodeType.lastIndexOf('.') + 1) : nodeType;
|
|
4480
|
+
var tool = null;
|
|
4481
|
+
for (const name of [nodeType, base]) {
|
|
4482
|
+
if (!tool && r.snap.decideRules.some(function (d) { return d.ir.on === 'tool_call' && (d.ir.tools || []).indexOf(name) !== -1; })) tool = name;
|
|
4483
|
+
}
|
|
4484
|
+
if (!tool) continue;
|
|
4485
|
+
const rules = r.snap.decideRules.filter(function (d) { return d.ir.on === 'tool_call' && (d.ir.tools || []).indexOf(tool) !== -1; });
|
|
4486
|
+
const budget = Math.min(${budget}, ${deadline} - (Date.now() - started) - ${margin});
|
|
4487
|
+
const dec = budget < ${minCall}
|
|
4488
|
+
? intuticDecideUnavailable(rules, tool, 'no time was left for the decide rules on ' + tool + ' within the gate deadline')
|
|
4489
|
+
: await intuticDecideCheck(tool, node.parameters == null ? {} : node.parameters, budget, rules);
|
|
4490
|
+
if (dec.outcome === 'allow') {
|
|
4491
|
+
// A shadow rule whose engine could not answer: recorded, and the workflow goes on.
|
|
4492
|
+
if (dec.reason) { try { record('tool_would_block', 'n8n:' + nodeName, 'GOVERNANCE_UNAVAILABLE — ' + dec.reason); } catch (e) {} }
|
|
4493
|
+
continue;
|
|
4494
|
+
}
|
|
4495
|
+
const reason = dec.reason + (dec.ruleId ? ' [' + dec.ruleId + ']' : '') + ' (node "' + nodeName + '", type ' + nodeType + ')';
|
|
4496
|
+
const shown = r.snap.opaque ? INTUTIC_OPAQUE_MESSAGE + ' (node "' + nodeName + '")' : reason;
|
|
4497
|
+
try { console.error('[Intutic Governance] BLOCKED: ' + shown + ' (refusal code ' + (dec.code || 'DECIDE_ESCALATED') + ')'); } catch (e) {}
|
|
4498
|
+
try { record('tool_blocked', 'n8n:' + nodeName, reason); } catch (e) {}
|
|
4499
|
+
throw new Error('[Intutic Governance] BLOCKED: ' + shown);
|
|
4500
|
+
}
|
|
2869
4501
|
}
|
|
2870
4502
|
// ── end Intutic gate body ────────────────────────────────────────────────────
|
|
2871
4503
|
`;
|