damage-control-dev 0.1.0__py3-none-any.whl
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.
- damage_control/__init__.py +9 -0
- damage_control/__main__.py +7 -0
- damage_control/adapter.py +179 -0
- damage_control/audit.py +432 -0
- damage_control/breakglass.py +155 -0
- damage_control/claude_code.py +148 -0
- damage_control/cli.py +173 -0
- damage_control/engine.py +10656 -0
- damage_control/groups.py +114 -0
- damage_control/harness_config.py +205 -0
- damage_control/hook.py +54 -0
- damage_control/install.py +618 -0
- damage_control/packs.py +219 -0
- damage_control/policy.py +323 -0
- damage_control/profile.py +177 -0
- damage_control/rules/aws.json +85 -0
- damage_control/rules/aws.yaml +76 -0
- damage_control/rules/cloud-hosting.json +162 -0
- damage_control/rules/cloud-hosting.yaml +163 -0
- damage_control/rules/containers.json +67 -0
- damage_control/rules/containers.yaml +69 -0
- damage_control/rules/control-plane.json +7 -0
- damage_control/rules/control-plane.yaml +21 -0
- damage_control/rules/core.json +332 -0
- damage_control/rules/core.yaml +441 -0
- damage_control/rules/databases.json +117 -0
- damage_control/rules/databases.yaml +123 -0
- damage_control/rules/firebase.json +34 -0
- damage_control/rules/firebase.yaml +30 -0
- damage_control/rules/gcp.json +60 -0
- damage_control/rules/gcp.yaml +57 -0
- damage_control/rules/git.json +119 -0
- damage_control/rules/git.yaml +120 -0
- damage_control/rules/infrastructure.json +69 -0
- damage_control/rules/infrastructure.yaml +66 -0
- damage_control/rules/mcp-tools.json +11 -0
- damage_control/rules/mcp-tools.yaml +47 -0
- damage_control/rules/outbound.json +40 -0
- damage_control/rules/outbound.yaml +58 -0
- damage_control/rules/payloads.json +120 -0
- damage_control/rules/payloads.yaml +155 -0
- damage_control/rules/publish.json +46 -0
- damage_control/rules/publish.yaml +46 -0
- damage_control/rules/registry.json +10 -0
- damage_control/rules/registry.yaml +11 -0
- damage_control/rules/remote.json +31 -0
- damage_control/rules/remote.yaml +53 -0
- damage_control/rules/tmux.json +17 -0
- damage_control/rules/tmux.yaml +16 -0
- damage_control/ruleset.py +94 -0
- damage_control/tooldefs/aws.json +140 -0
- damage_control/tooldefs/aws.yaml +119 -0
- damage_control/tooldefs/docker.json +146 -0
- damage_control/tooldefs/docker.yaml +123 -0
- damage_control/tooldefs/gcp.json +186 -0
- damage_control/tooldefs/gcp.yaml +158 -0
- damage_control/tooldefs/gh.json +172 -0
- damage_control/tooldefs/gh.yaml +145 -0
- damage_control/tooldefs/git.json +166 -0
- damage_control/tooldefs/git.yaml +143 -0
- damage_control/tooldefs/kubectl.json +101 -0
- damage_control/tooldefs/kubectl.yaml +85 -0
- damage_control/tooldefs/npm.json +77 -0
- damage_control/tooldefs/npm.yaml +65 -0
- damage_control/tooldefs/terraform.json +68 -0
- damage_control/tooldefs/terraform.yaml +57 -0
- damage_control/tooldefs/uv.json +112 -0
- damage_control/tooldefs/uv.yaml +97 -0
- damage_control_dev-0.1.0.dist-info/METADATA +15 -0
- damage_control_dev-0.1.0.dist-info/RECORD +74 -0
- damage_control_dev-0.1.0.dist-info/WHEEL +4 -0
- damage_control_dev-0.1.0.dist-info/entry_points.txt +2 -0
- damage_control_dev-0.1.0.dist-info/licenses/LICENSE +201 -0
- damage_control_dev-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Outbound communication damage control rules
|
|
2
|
+
#
|
|
3
|
+
# Outward-facing "send" verbs — email, SMS, push. These are irreversible (you
|
|
4
|
+
# can't un-send) and reach real people, so they must NOT sail through an
|
|
5
|
+
# UNATTENDED (scheduler) session silently. They're classified `ask: true`:
|
|
6
|
+
# - interactive bypass/auto session → allow (no friction, the common case)
|
|
7
|
+
# - interactive non-bypass session → confirm prompt
|
|
8
|
+
# - UNATTENDED (AGENTWIRE_UNATTENDED) → fail closed: block + email owner,
|
|
9
|
+
# unless the rule id is on the task's `unattended_allow`.
|
|
10
|
+
#
|
|
11
|
+
# These patterns gate the Bash shell-out (an agent or a scheduled task's `post:`
|
|
12
|
+
# step running `agentwire email`/`quo`). Agents inside agentwire sessions usually
|
|
13
|
+
# send via the MCP tools `email_send` / `quo_send` instead — those are gated by
|
|
14
|
+
# the dedicated `mcp-tool-damage-control.py` PreToolUse hook (#457), which
|
|
15
|
+
# synthesizes the equivalent `agentwire email`/`quo` command and reuses
|
|
16
|
+
# `outbound.agentwire-email` / `outbound.agentwire-quo` below verbatim (same IDs,
|
|
17
|
+
# same `unattended_allow`). See docs/wiki/internals/damage-control.md.
|
|
18
|
+
|
|
19
|
+
bashToolPatterns:
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# TWILIO (SMS / messaging)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
- pattern: '\btwilio\s+api[:\w.]*messages[:\w.]*create\b'
|
|
24
|
+
reason: twilio messages create (sends an SMS/MMS)
|
|
25
|
+
ask: true
|
|
26
|
+
id: outbound.twilio-messages
|
|
27
|
+
anchored: true
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# AWS messaging (SES email, SNS publish)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
- pattern: '\baws\s+ses(v2)?\s+send-email\b'
|
|
33
|
+
reason: aws ses send-email (sends an external email)
|
|
34
|
+
ask: true
|
|
35
|
+
id: outbound.aws-ses
|
|
36
|
+
anchored: true
|
|
37
|
+
|
|
38
|
+
- pattern: '\baws\s+sns\s+publish\b'
|
|
39
|
+
reason: aws sns publish (sends an SMS/notification)
|
|
40
|
+
ask: true
|
|
41
|
+
id: outbound.aws-sns
|
|
42
|
+
anchored: true
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# GENERIC MAIL TRANSPORTS
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
- pattern: '\bsendmail\b'
|
|
48
|
+
reason: sendmail (sends external email)
|
|
49
|
+
ask: true
|
|
50
|
+
id: outbound.sendmail
|
|
51
|
+
anchored: true
|
|
52
|
+
|
|
53
|
+
- pattern: '\b(mail|mailx)\s+(?:(?!--(?:\s|$))[^\s;&|]+\s+)*?-s\b'
|
|
54
|
+
pattern_src: '\b(mail|mailx)\s+{ARGS}-s\b'
|
|
55
|
+
reason: mail -s (sends external email)
|
|
56
|
+
ask: true
|
|
57
|
+
id: outbound.mail
|
|
58
|
+
anchored: true
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bashToolPatterns": [
|
|
3
|
+
{
|
|
4
|
+
"pattern": "shutil\\.rmtree",
|
|
5
|
+
"reason": "shutil.rmtree recursive delete (equivalent to rm -rf)",
|
|
6
|
+
"id": "core.shutil-rmtree-recursive-delete-equivalent-to-rm-rf",
|
|
7
|
+
"anchored": false
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"pattern": "\\bperl\\b.*\\bunlink\\b",
|
|
11
|
+
"reason": "perl unlink delete (use git clean or manual cleanup)",
|
|
12
|
+
"bypassable": true,
|
|
13
|
+
"id": "core.perl-unlink-delete-use-git-clean-or-manual-cleanup",
|
|
14
|
+
"anchored": false
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"pattern": "\\bmongosh.*dropDatabase",
|
|
18
|
+
"reason": "MongoDB dropDatabase",
|
|
19
|
+
"id": "databases.mongodb-dropdatabase",
|
|
20
|
+
"anchored": false
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"pattern": "\\bmongo.*dropDatabase",
|
|
24
|
+
"reason": "MongoDB dropDatabase",
|
|
25
|
+
"id": "databases.mongodb-dropdatabase-2",
|
|
26
|
+
"anchored": false
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"pattern": "DELETE\\s+FROM\\s+\\w+\\s*;",
|
|
30
|
+
"reason": "DELETE without WHERE clause (will delete ALL rows)",
|
|
31
|
+
"id": "databases.delete-without-where-clause-will-delete-all-rows",
|
|
32
|
+
"anchored": false
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"pattern": "DELETE\\s+FROM\\s+\\w+\\s*$",
|
|
36
|
+
"reason": "DELETE without WHERE clause (will delete ALL rows)",
|
|
37
|
+
"id": "databases.delete-without-where-clause-will-delete-all-rows-2",
|
|
38
|
+
"anchored": false
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"pattern": "DELETE\\s+\\*\\s+FROM",
|
|
42
|
+
"reason": "DELETE * (will delete ALL rows)",
|
|
43
|
+
"id": "databases.delete-will-delete-all-rows",
|
|
44
|
+
"anchored": false
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"pattern": "\\bTRUNCATE\\s+TABLE\\b",
|
|
48
|
+
"reason": "TRUNCATE TABLE (will delete ALL rows)",
|
|
49
|
+
"id": "databases.truncate-table-will-delete-all-rows",
|
|
50
|
+
"anchored": false
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"pattern": "\\bDROP\\s+TABLE\\b",
|
|
54
|
+
"reason": "DROP TABLE",
|
|
55
|
+
"id": "databases.drop-table",
|
|
56
|
+
"anchored": false
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"pattern": "\\bDROP\\s+DATABASE\\b",
|
|
60
|
+
"reason": "DROP DATABASE",
|
|
61
|
+
"id": "databases.drop-database",
|
|
62
|
+
"anchored": false
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"pattern": "\\bDELETE\\s+FROM\\s+\\w+\\s+WHERE\\b.*\\bid\\s*=",
|
|
66
|
+
"reason": "SQL DELETE with specific ID",
|
|
67
|
+
"ask": true,
|
|
68
|
+
"id": "databases.sql-delete-with-specific-id",
|
|
69
|
+
"anchored": false
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"pattern": "\\bpsql\\b.*\\b(INSERT\\s+INTO|UPDATE\\s+\\w+\\s+SET|ALTER\\s+TABLE|CREATE\\s+TABLE|GRANT\\s+|REVOKE\\s+)",
|
|
73
|
+
"reason": "psql executing a write statement (INSERT/UPDATE/ALTER/CREATE/GRANT)",
|
|
74
|
+
"ask": true,
|
|
75
|
+
"id": "db.psql-write",
|
|
76
|
+
"anchored": false
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"pattern": "\\b(mysql|mariadb)\\b.*\\b(INSERT\\s+INTO|UPDATE\\s+\\w+\\s+SET|ALTER\\s+TABLE|CREATE\\s+TABLE|GRANT\\s+|REVOKE\\s+)",
|
|
80
|
+
"reason": "mysql executing a write statement (INSERT/UPDATE/ALTER/CREATE/GRANT)",
|
|
81
|
+
"ask": true,
|
|
82
|
+
"id": "db.mysql-write",
|
|
83
|
+
"anchored": false
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"pattern": "\\bmongosh?\\b.*\\.(insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|replaceOne|bulkWrite)\\b",
|
|
87
|
+
"reason": "mongosh executing a write operation (insert/update/delete)",
|
|
88
|
+
"ask": true,
|
|
89
|
+
"id": "db.mongosh-write",
|
|
90
|
+
"anchored": false
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"pattern": "\\bdocker\\s+rm\\s+.*-f.*\\$\\(docker\\s+ps",
|
|
94
|
+
"reason": "docker rm -f $(docker ps) (force removes containers)",
|
|
95
|
+
"id": "containers.docker-rm-f-docker-ps-force-removes-containers",
|
|
96
|
+
"anchored": false
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"pattern": "\\bgit\\b[^;&|]*\\s-c\\s*(?:alias\\.[^.=\\s]+=!|(?:core\\.(?:sshcommand|fsmonitor|pager|editor|askpass|alternaterefscommand)|sequence\\.editor|gpg\\.program|diff\\.external|credential\\.helper|uploadpack\\.packobjectshook|filter\\.[^.=\\s]+\\.(?:clean|smudge|process)|difftool\\.[^.=\\s]+\\.cmd|mergetool\\.[^.=\\s]+\\.cmd|merge\\.[^.=\\s]+\\.driver|remote\\.[^.=\\s]+\\.(?:uploadpack|receivepack)|browser\\.[^.=\\s]+\\.cmd|man\\.[^.=\\s]+\\.cmd)=)",
|
|
100
|
+
"reason": "git -c sets a config key that names a program git will execute",
|
|
101
|
+
"ask": true,
|
|
102
|
+
"id": "git.config-exec-key",
|
|
103
|
+
"anchored": false
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"pattern": "(?:^|[;&|(`]|\\$\\(|(?:\\s-c|\\bssh\\s+\\S+)\\s+['\"])\\s*(?:\\w+=\\S*\\s+|(?:sudo|doas|env|nohup|time|command|exec|timeout|gtimeout|nice|xargs|stdbuf|ionice|setsid|caffeinate|unbuffer)\\s+(?:-\\S+\\s+|\\d+[smhd]?\\s+)*)*git\\b[^;&|]*?\\sconfig\\b(?![^;&|]*\\s(?:--(?:get|unset|list|edit)|-l\\b|-e\\b))[^;&|]*?\\s(?:core\\.(?:sshcommand|fsmonitor|pager|editor|askpass|alternaterefscommand)|sequence\\.editor|gpg\\.program|diff\\.external|credential\\.helper|uploadpack\\.packobjectshook|filter\\.[^.\\s]+\\.(?:clean|smudge|process)|difftool\\.[^.\\s]+\\.cmd|mergetool\\.[^.\\s]+\\.cmd|merge\\.[^.\\s]+\\.driver|remote\\.[^.\\s]+\\.(?:uploadpack|receivepack)|browser\\.[^.\\s]+\\.cmd|man\\.[^.\\s]+\\.cmd)\\s+(?!(?:(?:vim|nvim|vi|view|nano|pico|micro|emacs|ed|ex|less|more|cat|bat|most|true|false|code|subl|mate|zed)(?:\\s+-{1,2}[A-Za-z][\\w-]*(?:=[\\w.-]+)?)*(?=(?:\\s+--(?:global|local|system|worktree|replace-all|add))*\\s*(?:$|[;&|)`]))|(['\\\"])(?:vim|nvim|vi|view|nano|pico|micro|emacs|ed|ex|less|more|cat|bat|most|true|false|code|subl|mate|zed)(?:\\s+-{1,2}[A-Za-z][\\w-]*(?:=[\\w.-]+)?)*\\1(?=(?:\\s+--(?:global|local|system|worktree|replace-all|add))*\\s*(?:$|[;&|)`]))))[^\\s\\x00]",
|
|
107
|
+
"reason": "git config sets a key that names a program git will execute",
|
|
108
|
+
"ask": true,
|
|
109
|
+
"id": "git.config-set-exec-key",
|
|
110
|
+
"anchored": false
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"pattern": "\\bgit\\s+config\\b(?![^;&|]*\\s(?:--(?:get|unset|list|edit)|-l\\b|-e\\b))[^;&|]*?\\salias\\.[^.=\\s]+\\s+['\"]?!",
|
|
114
|
+
"reason": "git config sets a shell-command alias (alias.x = !cmd)",
|
|
115
|
+
"ask": true,
|
|
116
|
+
"id": "git.config-set-alias-bang",
|
|
117
|
+
"anchored": false
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Wrapped-payload damage control rules
|
|
2
|
+
#
|
|
3
|
+
# Every rule here matches text that arrives INSIDE another command's quoted
|
|
4
|
+
# argument -- a SQL statement handed to psql, a script handed to python -c /
|
|
5
|
+
# perl -e / mongosh --eval, a $(...) substitution. They are the rules that fail
|
|
6
|
+
# the anchoring shape test, so they live together in one uniformly UNANCHORED
|
|
7
|
+
# file (#915).
|
|
8
|
+
#
|
|
9
|
+
# WHY A FILE AND NOT A PER-RULE FLAG. `anchored: true` swaps the haystack for
|
|
10
|
+
# masked_subcommands(), which blanks every fully-quoted token containing
|
|
11
|
+
# whitespace regardless of position. That is lossless for a command-prefix rule
|
|
12
|
+
# and fatal here: masking removes exactly the text these rules exist to catch.
|
|
13
|
+
# Anchoring is therefore decided per FILE, gated on the git.yaml shape test --
|
|
14
|
+
# every rule command-prefix shaped AND the tool takes no inner-command payload
|
|
15
|
+
# -- so there is no per-rule skip-list to keep in sync (see
|
|
16
|
+
# ~/.claude/rules/architecture.md). remote.yaml is the other unanchored file:
|
|
17
|
+
# ssh's payload is the same shape, and its rules are kept whole there because
|
|
18
|
+
# they double-cover the local rules in core.yaml.
|
|
19
|
+
#
|
|
20
|
+
# Each rule carries an explicit `id:` pinned to the id it had before the move,
|
|
21
|
+
# so `safety.disabled_rules` / `unattended_allow` entries keep working.
|
|
22
|
+
#
|
|
23
|
+
# DO NOT add `anchored: true` to anything in this file. The invariant is
|
|
24
|
+
# enforced by tests/unit/test_damage_control_payload_anchoring.py.
|
|
25
|
+
|
|
26
|
+
bashToolPatterns:
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# INTERPRETER / CLIENT EVAL PAYLOADS
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
- pattern: 'shutil\.rmtree'
|
|
33
|
+
reason: shutil.rmtree recursive delete (equivalent to rm -rf)
|
|
34
|
+
id: core.shutil-rmtree-recursive-delete-equivalent-to-rm-rf
|
|
35
|
+
anchored: false
|
|
36
|
+
|
|
37
|
+
- pattern: '\bperl\b.*\bunlink\b'
|
|
38
|
+
reason: perl unlink delete (use git clean or manual cleanup)
|
|
39
|
+
bypassable: true
|
|
40
|
+
id: core.perl-unlink-delete-use-git-clean-or-manual-cleanup
|
|
41
|
+
anchored: false
|
|
42
|
+
|
|
43
|
+
- pattern: '\bmongosh.*dropDatabase'
|
|
44
|
+
reason: MongoDB dropDatabase
|
|
45
|
+
id: databases.mongodb-dropdatabase
|
|
46
|
+
anchored: false
|
|
47
|
+
|
|
48
|
+
- pattern: '\bmongo.*dropDatabase'
|
|
49
|
+
reason: MongoDB dropDatabase
|
|
50
|
+
id: databases.mongodb-dropdatabase-2
|
|
51
|
+
anchored: false
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# SQL STATEMENTS
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
- pattern: 'DELETE\s+FROM\s+\w+\s*;'
|
|
58
|
+
reason: DELETE without WHERE clause (will delete ALL rows)
|
|
59
|
+
id: databases.delete-without-where-clause-will-delete-all-rows
|
|
60
|
+
anchored: false
|
|
61
|
+
|
|
62
|
+
- pattern: 'DELETE\s+FROM\s+\w+\s*$'
|
|
63
|
+
reason: DELETE without WHERE clause (will delete ALL rows)
|
|
64
|
+
id: databases.delete-without-where-clause-will-delete-all-rows-2
|
|
65
|
+
anchored: false
|
|
66
|
+
|
|
67
|
+
- pattern: 'DELETE\s+\*\s+FROM'
|
|
68
|
+
reason: DELETE * (will delete ALL rows)
|
|
69
|
+
id: databases.delete-will-delete-all-rows
|
|
70
|
+
anchored: false
|
|
71
|
+
|
|
72
|
+
- pattern: '\bTRUNCATE\s+TABLE\b'
|
|
73
|
+
reason: TRUNCATE TABLE (will delete ALL rows)
|
|
74
|
+
id: databases.truncate-table-will-delete-all-rows
|
|
75
|
+
anchored: false
|
|
76
|
+
|
|
77
|
+
- pattern: '\bDROP\s+TABLE\b'
|
|
78
|
+
reason: DROP TABLE
|
|
79
|
+
id: databases.drop-table
|
|
80
|
+
anchored: false
|
|
81
|
+
|
|
82
|
+
- pattern: '\bDROP\s+DATABASE\b'
|
|
83
|
+
reason: DROP DATABASE
|
|
84
|
+
id: databases.drop-database
|
|
85
|
+
anchored: false
|
|
86
|
+
|
|
87
|
+
- pattern: '\bDELETE\s+FROM\s+\w+\s+WHERE\b.*\bid\s*='
|
|
88
|
+
reason: SQL DELETE with specific ID
|
|
89
|
+
ask: true
|
|
90
|
+
id: databases.sql-delete-with-specific-id
|
|
91
|
+
anchored: false
|
|
92
|
+
|
|
93
|
+
- pattern: '\bpsql\b.*\b(INSERT\s+INTO|UPDATE\s+\w+\s+SET|ALTER\s+TABLE|CREATE\s+TABLE|GRANT\s+|REVOKE\s+)'
|
|
94
|
+
reason: psql executing a write statement (INSERT/UPDATE/ALTER/CREATE/GRANT)
|
|
95
|
+
ask: true
|
|
96
|
+
id: db.psql-write
|
|
97
|
+
anchored: false
|
|
98
|
+
|
|
99
|
+
- pattern: '\b(mysql|mariadb)\b.*\b(INSERT\s+INTO|UPDATE\s+\w+\s+SET|ALTER\s+TABLE|CREATE\s+TABLE|GRANT\s+|REVOKE\s+)'
|
|
100
|
+
reason: mysql executing a write statement (INSERT/UPDATE/ALTER/CREATE/GRANT)
|
|
101
|
+
ask: true
|
|
102
|
+
id: db.mysql-write
|
|
103
|
+
anchored: false
|
|
104
|
+
|
|
105
|
+
- pattern: '\bmongosh?\b.*\.(insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|replaceOne|bulkWrite)\b'
|
|
106
|
+
reason: mongosh executing a write operation (insert/update/delete)
|
|
107
|
+
ask: true
|
|
108
|
+
id: db.mongosh-write
|
|
109
|
+
anchored: false
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# COMMAND SUBSTITUTION
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
- pattern: '\bdocker\s+rm\s+.*-f.*\$\(docker\s+ps'
|
|
116
|
+
reason: docker rm -f $(docker ps) (force removes containers)
|
|
117
|
+
id: containers.docker-rm-f-docker-ps-force-removes-containers
|
|
118
|
+
anchored: false
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# GIT CONFIG KEYS THAT NAME AN EXECUTABLE (#921)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# A subset of `git -c` keys name a program git will RUN (core.sshCommand,
|
|
124
|
+
# core.fsmonitor, core.pager, ...). The value is additionally re-scanned as a
|
|
125
|
+
# command in its own right (`_git_exec_config_payloads`), so a dangerous
|
|
126
|
+
# payload hits its own block rule; this rule is the confirm tier for
|
|
127
|
+
# payloads no rule recognizes. Unanchored: the key sits in an option's
|
|
128
|
+
# attached/separate value, never at command position.
|
|
129
|
+
|
|
130
|
+
- pattern: '\bgit\b[^;&|]*\s-c\s*(?:alias\.[^.=\s]+=!|(?:core\.(?:sshcommand|fsmonitor|pager|editor|askpass|alternaterefscommand)|sequence\.editor|gpg\.program|diff\.external|credential\.helper|uploadpack\.packobjectshook|filter\.[^.=\s]+\.(?:clean|smudge|process)|difftool\.[^.=\s]+\.cmd|mergetool\.[^.=\s]+\.cmd|merge\.[^.=\s]+\.driver|remote\.[^.=\s]+\.(?:uploadpack|receivepack)|browser\.[^.=\s]+\.cmd|man\.[^.=\s]+\.cmd)=)'
|
|
131
|
+
reason: git -c sets a config key that names a program git will execute
|
|
132
|
+
ask: true
|
|
133
|
+
id: git.config-exec-key
|
|
134
|
+
anchored: false
|
|
135
|
+
|
|
136
|
+
# Set-form `git config <exec-key> <value>` (#1133): git runs the value through
|
|
137
|
+
# a shell, so only a known editor/pager word is bare-safe -- either bare, or
|
|
138
|
+
# quoted with plain flags (`'vim -f'`, `'code --wait'`). Anything else asks:
|
|
139
|
+
# a separator or substitution inside quotes (`'less;sh'`), an escape
|
|
140
|
+
# (`less\;sh`), `$'..'`, an unknown program. Unanchored: the quoting and
|
|
141
|
+
# escaping that decide safety are invisible in the masked view. Command
|
|
142
|
+
# position is required, so a message that merely quotes the command passes.
|
|
143
|
+
- pattern: '(?:^|[;&|(`]|\$\(|(?:\s-c|\bssh\s+\S+)\s+[''"])\s*(?:\w+=\S*\s+|(?:sudo|doas|env|nohup|time|command|exec|timeout|gtimeout|nice|xargs|stdbuf|ionice|setsid|caffeinate|unbuffer)\s+(?:-\S+\s+|\d+[smhd]?\s+)*)*git\b[^;&|]*?\sconfig\b(?![^;&|]*\s(?:--(?:get|unset|list|edit)|-l\b|-e\b))[^;&|]*?\s(?:core\.(?:sshcommand|fsmonitor|pager|editor|askpass|alternaterefscommand)|sequence\.editor|gpg\.program|diff\.external|credential\.helper|uploadpack\.packobjectshook|filter\.[^.\s]+\.(?:clean|smudge|process)|difftool\.[^.\s]+\.cmd|mergetool\.[^.\s]+\.cmd|merge\.[^.\s]+\.driver|remote\.[^.\s]+\.(?:uploadpack|receivepack)|browser\.[^.\s]+\.cmd|man\.[^.\s]+\.cmd)\s+(?!(?:(?:vim|nvim|vi|view|nano|pico|micro|emacs|ed|ex|less|more|cat|bat|most|true|false|code|subl|mate|zed)(?:\s+-{1,2}[A-Za-z][\w-]*(?:=[\w.-]+)?)*(?=(?:\s+--(?:global|local|system|worktree|replace-all|add))*\s*(?:$|[;&|)`]))|([''\"])(?:vim|nvim|vi|view|nano|pico|micro|emacs|ed|ex|less|more|cat|bat|most|true|false|code|subl|mate|zed)(?:\s+-{1,2}[A-Za-z][\w-]*(?:=[\w.-]+)?)*\1(?=(?:\s+--(?:global|local|system|worktree|replace-all|add))*\s*(?:$|[;&|)`]))))[^\s\x00]'
|
|
144
|
+
reason: git config sets a key that names a program git will execute
|
|
145
|
+
ask: true
|
|
146
|
+
id: git.config-set-exec-key
|
|
147
|
+
anchored: false
|
|
148
|
+
|
|
149
|
+
# Set-form alias with the bang form (`git config alias.x '!cmd'`): the `!` sits
|
|
150
|
+
# inside a quoted value, which the anchored git.yaml rules see masked.
|
|
151
|
+
- pattern: '\bgit\s+config\b(?![^;&|]*\s(?:--(?:get|unset|list|edit)|-l\b|-e\b))[^;&|]*?\salias\.[^.=\s]+\s+[''"]?!'
|
|
152
|
+
reason: git config sets a shell-command alias (alias.x = !cmd)
|
|
153
|
+
ask: true
|
|
154
|
+
id: git.config-set-alias-bang
|
|
155
|
+
anchored: false
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bashToolPatterns": [
|
|
3
|
+
{
|
|
4
|
+
"pattern": "\\bcargo\\s+publish\\b",
|
|
5
|
+
"reason": "cargo publish (publishes a crate to crates.io)",
|
|
6
|
+
"ask": true,
|
|
7
|
+
"id": "publish.cargo",
|
|
8
|
+
"anchored": true
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"pattern": "\\bpoetry\\s+publish\\b",
|
|
12
|
+
"reason": "poetry publish (publishes a package to PyPI)",
|
|
13
|
+
"ask": true,
|
|
14
|
+
"id": "publish.poetry",
|
|
15
|
+
"anchored": true
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"pattern": "\\btwine\\s+upload\\b",
|
|
19
|
+
"reason": "twine upload (publishes a package to PyPI)",
|
|
20
|
+
"ask": true,
|
|
21
|
+
"id": "publish.twine",
|
|
22
|
+
"anchored": true
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"pattern": "\\b(pnpm|yarn)\\s+publish\\b",
|
|
26
|
+
"reason": "pnpm/yarn publish (publishes a package to npm registry)",
|
|
27
|
+
"ask": true,
|
|
28
|
+
"id": "publish.pnpm-yarn",
|
|
29
|
+
"anchored": true
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"pattern": "\\bgem\\s+push\\b",
|
|
33
|
+
"reason": "gem push (publishes a gem to RubyGems)",
|
|
34
|
+
"ask": true,
|
|
35
|
+
"id": "publish.gem",
|
|
36
|
+
"anchored": true
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"pattern": "\\bmvn\\s+(deploy|.*\\bdeploy:deploy)\\b",
|
|
40
|
+
"reason": "mvn deploy (publishes artifacts to a Maven repository)",
|
|
41
|
+
"ask": true,
|
|
42
|
+
"id": "publish.maven",
|
|
43
|
+
"anchored": true
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Package publish damage control rules
|
|
2
|
+
#
|
|
3
|
+
# Publishing to a public registry is irreversible and outward-facing (downstream
|
|
4
|
+
# users pull what you push), so it must be caught in UNATTENDED sessions. All
|
|
5
|
+
# `ask: true` — see outbound.yaml for how `ask` resolves per session mode.
|
|
6
|
+
#
|
|
7
|
+
# Note: `npm publish` and `uv publish` are already `ask`-tier via the write-access
|
|
8
|
+
# tooldef commands (agentwire/tooldefs/{npm,uv}.yaml). These rules cover the
|
|
9
|
+
# registries that have no tooldef.
|
|
10
|
+
|
|
11
|
+
bashToolPatterns:
|
|
12
|
+
- pattern: '\bcargo\s+publish\b'
|
|
13
|
+
reason: cargo publish (publishes a crate to crates.io)
|
|
14
|
+
ask: true
|
|
15
|
+
id: publish.cargo
|
|
16
|
+
anchored: true
|
|
17
|
+
|
|
18
|
+
- pattern: '\bpoetry\s+publish\b'
|
|
19
|
+
reason: poetry publish (publishes a package to PyPI)
|
|
20
|
+
ask: true
|
|
21
|
+
id: publish.poetry
|
|
22
|
+
anchored: true
|
|
23
|
+
|
|
24
|
+
- pattern: '\btwine\s+upload\b'
|
|
25
|
+
reason: twine upload (publishes a package to PyPI)
|
|
26
|
+
ask: true
|
|
27
|
+
id: publish.twine
|
|
28
|
+
anchored: true
|
|
29
|
+
|
|
30
|
+
- pattern: '\b(pnpm|yarn)\s+publish\b'
|
|
31
|
+
reason: pnpm/yarn publish (publishes a package to npm registry)
|
|
32
|
+
ask: true
|
|
33
|
+
id: publish.pnpm-yarn
|
|
34
|
+
anchored: true
|
|
35
|
+
|
|
36
|
+
- pattern: '\bgem\s+push\b'
|
|
37
|
+
reason: gem push (publishes a gem to RubyGems)
|
|
38
|
+
ask: true
|
|
39
|
+
id: publish.gem
|
|
40
|
+
anchored: true
|
|
41
|
+
|
|
42
|
+
- pattern: '\bmvn\s+(deploy|.*\bdeploy:deploy)\b'
|
|
43
|
+
reason: mvn deploy (publishes artifacts to a Maven repository)
|
|
44
|
+
ask: true
|
|
45
|
+
id: publish.maven
|
|
46
|
+
anchored: true
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Package registry damage control (default-on group `registry`, #1141)
|
|
2
|
+
#
|
|
3
|
+
# Vendor-neutral registry operations that must stay protected on a fresh install,
|
|
4
|
+
# independent of whether the `publish` pack is enabled. `npm publish` itself is
|
|
5
|
+
# ask-tier through the npm tooldef, which belongs to this same group.
|
|
6
|
+
|
|
7
|
+
bashToolPatterns:
|
|
8
|
+
- id: registry.npm-unpublish-removes-package-from-registry
|
|
9
|
+
pattern: '\bnpm\s+unpublish\b'
|
|
10
|
+
reason: npm unpublish (removes package from registry)
|
|
11
|
+
anchored: true
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bashToolPatterns": [
|
|
3
|
+
{
|
|
4
|
+
"id": "remote.ssh-remote-reboot",
|
|
5
|
+
"pattern": "\\bssh\\s+.*\\breboot\\b",
|
|
6
|
+
"reason": "SSH remote reboot",
|
|
7
|
+
"ask": true,
|
|
8
|
+
"anchored": false
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "remote.ssh-remote-shutdown",
|
|
12
|
+
"pattern": "\\bssh\\s+.*\\bshutdown\\b",
|
|
13
|
+
"reason": "SSH remote shutdown",
|
|
14
|
+
"ask": true,
|
|
15
|
+
"anchored": false
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "remote.ssh-remote-service-stop",
|
|
19
|
+
"pattern": "\\bssh\\s+.*\\bsystemctl\\s+stop\\b",
|
|
20
|
+
"reason": "SSH remote service stop",
|
|
21
|
+
"ask": true,
|
|
22
|
+
"anchored": false
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "remote.ssh-remote-docker-rm-f",
|
|
26
|
+
"pattern": "\\bssh\\s+.*\\bdocker\\s+rm\\s+.*-f",
|
|
27
|
+
"reason": "SSH remote docker rm -f",
|
|
28
|
+
"anchored": false
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Remote execution damage control rules
|
|
2
|
+
# SSH remote execution safeguards
|
|
3
|
+
#
|
|
4
|
+
# MOSTLY SUPERSEDED BY THE WRAPPER-PAYLOAD RESCAN (#924). ssh's remote command
|
|
5
|
+
# is now extracted and re-scanned as a command in its own right
|
|
6
|
+
# (`_ssh_remote_payload` in agentwire/safety/_core.py), so EVERY rule —
|
|
7
|
+
# anchored ones included, at real command positions — applies to the wrapped
|
|
8
|
+
# form automatically. The hand-written ssh twins of local rules (rm -rf,
|
|
9
|
+
# sudo rm, mkfs, dd-to-device, dropdb, mysqladmin drop, redis FLUSHALL,
|
|
10
|
+
# docker system prune) were measured redundant against the full corpus and
|
|
11
|
+
# deleted; do not add new twins here — a rule written once applies over ssh.
|
|
12
|
+
#
|
|
13
|
+
# What remains is the ssh-ONLY surface: operations with no local rule twin
|
|
14
|
+
# (rebooting/stopping a REMOTE machine is a risk locally unruled), plus one
|
|
15
|
+
# rule that is deliberately STRICTER than its local tooldef tier.
|
|
16
|
+
#
|
|
17
|
+
# WHY THIS FILE STAYS UNANCHORED (#915)
|
|
18
|
+
#
|
|
19
|
+
# The dangerous text is ssh's quoted payload; `anchored: true` matches against
|
|
20
|
+
# masked_subcommands(), which blanks any fully-quoted token containing
|
|
21
|
+
# whitespace -- so anchoring would blank exactly the half these rules read.
|
|
22
|
+
#
|
|
23
|
+
# DO NOT add `anchored: true` to anything in this file. Enforced by
|
|
24
|
+
# tests/unit/test_damage_control_payload_anchoring.py.
|
|
25
|
+
|
|
26
|
+
bashToolPatterns:
|
|
27
|
+
# Remote machine-state operations — no local rule twin exists for these, so
|
|
28
|
+
# the rescan alone leaves them uncovered.
|
|
29
|
+
- id: remote.ssh-remote-reboot
|
|
30
|
+
pattern: '\bssh\s+.*\breboot\b'
|
|
31
|
+
reason: SSH remote reboot
|
|
32
|
+
ask: true
|
|
33
|
+
anchored: false
|
|
34
|
+
|
|
35
|
+
- id: remote.ssh-remote-shutdown
|
|
36
|
+
pattern: '\bssh\s+.*\bshutdown\b'
|
|
37
|
+
reason: SSH remote shutdown
|
|
38
|
+
ask: true
|
|
39
|
+
anchored: false
|
|
40
|
+
|
|
41
|
+
- id: remote.ssh-remote-service-stop
|
|
42
|
+
pattern: '\bssh\s+.*\bsystemctl\s+stop\b'
|
|
43
|
+
reason: SSH remote service stop
|
|
44
|
+
ask: true
|
|
45
|
+
anchored: false
|
|
46
|
+
|
|
47
|
+
# Deliberately STRICTER than the local tier: `docker rm -f` locally is
|
|
48
|
+
# ask-tier (tooldef); on a remote box it stays a hard block — deleting this
|
|
49
|
+
# twin would downgrade the wrapped form from block to ask.
|
|
50
|
+
- id: remote.ssh-remote-docker-rm-f
|
|
51
|
+
pattern: '\bssh\s+.*\bdocker\s+rm\s+.*-f'
|
|
52
|
+
reason: SSH remote docker rm -f
|
|
53
|
+
anchored: false
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bashToolPatterns": [
|
|
3
|
+
{
|
|
4
|
+
"id": "agentwire.tmux-kill-server-destroys-all-tmux-sessions-including-agentw",
|
|
5
|
+
"pattern": "\\btmux\\s+kill-server\\b",
|
|
6
|
+
"reason": "tmux kill-server (destroys ALL tmux sessions)",
|
|
7
|
+
"anchored": true
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "agentwire.tmux-kill-session-a-kills-all-except-current",
|
|
11
|
+
"pattern": "\\btmux\\s+kill-session\\s+.*-a\\b",
|
|
12
|
+
"reason": "tmux kill-session -a (kills all except current)",
|
|
13
|
+
"ask": true,
|
|
14
|
+
"anchored": true
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# tmux hazards (opt-in pack `tmux`)
|
|
2
|
+
#
|
|
3
|
+
# Destroying every tmux session at once is a hazard for anyone who keeps
|
|
4
|
+
# long-running work in tmux, agent or not.
|
|
5
|
+
|
|
6
|
+
bashToolPatterns:
|
|
7
|
+
- id: agentwire.tmux-kill-server-destroys-all-tmux-sessions-including-agentw
|
|
8
|
+
pattern: '\btmux\s+kill-server\b'
|
|
9
|
+
reason: tmux kill-server (destroys ALL tmux sessions)
|
|
10
|
+
anchored: true
|
|
11
|
+
|
|
12
|
+
- id: agentwire.tmux-kill-session-a-kills-all-except-current
|
|
13
|
+
pattern: '\btmux\s+kill-session\s+.*-a\b'
|
|
14
|
+
reason: tmux kill-session -a (kills all except current)
|
|
15
|
+
ask: true
|
|
16
|
+
anchored: true
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The enforced rule set as ``{stable id: "tier:fingerprint"}``, and the diff between two of them (#1209 M5).
|
|
2
|
+
|
|
3
|
+
``install.json`` records one of these at every acknowledged install. ``status`` compares the
|
|
4
|
+
current set against it ("ruleset changed since last acknowledged") and ``init --refresh``
|
|
5
|
+
prints the diff by id. A refresh that WIDENS what is permitted must say so (#916): a removed
|
|
6
|
+
rule, a lowered tier, a new allow-path and a new default unattended grant are marked ``!!``.
|
|
7
|
+
|
|
8
|
+
Standard library only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
TIER_RANK = {"block": 3, "ask": 2, "allow": 1}
|
|
16
|
+
_PATH_LISTS = (("zeroAccessPaths", "block"), ("readOnlyPaths", "block"), ("noDeletePaths", "block"))
|
|
17
|
+
ALLOW_NAMESPACE = "allowedPaths:"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Change(NamedTuple):
|
|
21
|
+
loosening: bool
|
|
22
|
+
text: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fingerprint(value: Any) -> str:
|
|
26
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()[:10]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _label(item: Any) -> str:
|
|
30
|
+
if isinstance(item, str):
|
|
31
|
+
return item
|
|
32
|
+
if isinstance(item, dict):
|
|
33
|
+
for key in ("path", "pattern", "entry", "id"):
|
|
34
|
+
if isinstance(item.get(key), str):
|
|
35
|
+
return item[key]
|
|
36
|
+
return json.dumps(item, sort_keys=True, default=str)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def digest(config: Dict[str, Any]) -> Dict[str, str]:
|
|
40
|
+
"""Every enforced rule of ``config`` (an effective config) keyed by its stable id."""
|
|
41
|
+
out: Dict[str, str] = {}
|
|
42
|
+
for rule in config.get("bashToolPatterns") or []:
|
|
43
|
+
if isinstance(rule, dict) and rule.get("id"):
|
|
44
|
+
out.setdefault(rule["id"], f"{'ask' if rule.get('ask') else 'block'}:{_fingerprint(rule.get('pattern'))}")
|
|
45
|
+
for policy in config.get("mcpToolPolicies") or []:
|
|
46
|
+
if isinstance(policy, dict) and policy.get("id"):
|
|
47
|
+
match = policy.get("match") or policy.get("tool") or policy.get("pattern")
|
|
48
|
+
out.setdefault(policy["id"], f"{policy.get('tier', 'allow')}:{_fingerprint(match)}")
|
|
49
|
+
for key, tier in _PATH_LISTS:
|
|
50
|
+
for item in config.get(key) or []:
|
|
51
|
+
out.setdefault(f"{key}:{_label(item)}", f"{tier}:-")
|
|
52
|
+
for item in config.get("allowedPaths") or []:
|
|
53
|
+
out.setdefault(f"{ALLOW_NAMESPACE}{_label(item)}", "allow:-")
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _tier(value: Optional[str]) -> str:
|
|
58
|
+
return (value or "").split(":", 1)[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def diff(old: Dict[str, str], new: Dict[str, str],
|
|
62
|
+
old_grants: Iterable[str] = (), new_grants: Iterable[str] = ()) -> List[Change]:
|
|
63
|
+
"""Added / removed / changed ids between two digests, plus new default grants. Loosening is flagged."""
|
|
64
|
+
changes: List[Change] = []
|
|
65
|
+
for rid in sorted(set(old) | set(new)):
|
|
66
|
+
before, after = old.get(rid), new.get(rid)
|
|
67
|
+
if before == after:
|
|
68
|
+
continue
|
|
69
|
+
allow_entry = rid.startswith(ALLOW_NAMESPACE)
|
|
70
|
+
if before is None:
|
|
71
|
+
changes.append(Change(allow_entry, f"+ {rid} ({_tier(after)})" + (" widens what is allowed" if allow_entry else "")))
|
|
72
|
+
elif after is None:
|
|
73
|
+
changes.append(Change(not allow_entry, f"- {rid} (was {_tier(before)})"))
|
|
74
|
+
elif _tier(before) != _tier(after):
|
|
75
|
+
lowered = TIER_RANK.get(_tier(after), 0) < TIER_RANK.get(_tier(before), 0)
|
|
76
|
+
changes.append(Change(lowered, f"~ {rid} {_tier(before)} -> {_tier(after)}"))
|
|
77
|
+
else:
|
|
78
|
+
changes.append(Change(False, f"~ {rid} pattern changed (review: a narrower pattern permits more)"))
|
|
79
|
+
for grant in sorted(set(new_grants) - set(old_grants)):
|
|
80
|
+
changes.append(Change(True, f"+ default unattended grant {grant}"))
|
|
81
|
+
return changes
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render(changes: List[Change]) -> List[str]:
|
|
85
|
+
return [f"{'!!' if c.loosening else ' '} {c.text}" for c in changes]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def has_loosening(changes: List[Change]) -> bool:
|
|
89
|
+
return any(c.loosening for c in changes)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def summary(changes: List[Change]) -> Tuple[int, int]:
|
|
93
|
+
"""``(total changes, loosening changes)``."""
|
|
94
|
+
return len(changes), sum(1 for c in changes if c.loosening)
|